Compare commits

..

4 Commits

12 changed files with 145 additions and 32 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.20 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.22 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+4 -9
View File
@@ -220,6 +220,10 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
## Implemented ## Implemented
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only. - Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
that changes it. If none changes the text, the operation keeps the text and advances one layout.
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary). - Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix. - Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
- Teams recent-word conversion uses a single word-selection command and validates the copied range. - Teams recent-word conversion uses a single word-selection command and validates the copied range.
@@ -284,15 +288,6 @@ Annotate it there, then copy the result. Opening the editor leaves the
clipboard and original history entry unchanged. Text items still open as text; clipboard and original history entry unchanged. Text items still open as text;
Enter and double-click still paste the selected item. Enter and double-click still paste the selected item.
Local builds made with `build.ps1` show **[LOCAL DEV]** in window titles and
the tray tooltip. The marker is enabled by `.local-dev` beside the executable;
release packages do not include that marker.
Fast/inertial wheel events use a shared batching handler in history, layout and
snippet lists, the log table, multiline text/details fields, combo boxes and
scrollable settings windows. High-resolution partial wheel steps are retained.
History pauses new preview loads while scrolling and keeps visible thumbnails
cached, preventing continuous flicker and needless redraws.
Pressing the history shortcut a second time pastes the second-most-recent item. Pressing the history shortcut a second time pastes the second-most-recent item.
## Data and migration ## Data and migration
Binary file not shown.
+7 -9
View File
@@ -1,24 +1,22 @@
# Latest Windows release # Latest Windows release
## v0.6.20 — 2026-10-31 ## v0.6.22 — 2026-10-31
## Changes ## Changes
- Open clipboard-history images in the built-in screenshot editor with **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor** in the context menu. PNG, DIB and DIBV5 open at full resolution through an encrypted local cache, without requiring notifications or changing the clipboard/original history entry. Copy the annotated result explicitly from the editor. - Recover partial selections during recent-text conversion, repeat conversion and undo: when copying returns a verified suffix of the expected text, extend the selection with paced Shift+Left presses and copy again. Replacement still requires a complete verified match.
- Fix continuous history flicker by keeping visible previews cached, invalidating only completed preview rows and preserving the top entry across background refreshes. - Recent-text suffix checks map physical keys through convertible layouts, including Cyrillic and mixed case. This covers reported suffixes such as `Switcher ` from `PuntoSwitcher `, `зштп ` from the layout equivalent of `snipping `, and `лувШт ` from `LinkedIn `.
- Handle fast/inertial wheels through one shared batching mechanism for history, layout/snippet lists, the log table, multiline text/details fields, combo boxes and scrollable settings windows. Preserve partial wheel deltas, clamp list boundaries and pause new history previews until scrolling stops. - Diagnostic context records the expected previous result and selection-recovery stages. Unrelated or empty copied ranges are not extended; input/context changes still cancel replacement.
- Mark local developer builds with **[LOCAL DEV]** in window titles and the tray tooltip. Release packages omit the development marker. This does not create a separate data profile.
- Right-clicking a history entry selects that entry before showing its actions. Enter and double-click continue to paste; text items still open in the full-text viewer.
## Validation ## Validation
Passed 406 native regressions, 473 input/UI checks, shared wheel batching tests, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. Manual testing confirmed image editing from history and resolved clipboard flicker and Logitech inertial-wheel beeping. Shared wheel tests cover lists, edits, tables, combo boxes and windows, partial steps, large bursts, selection preservation and list boundaries. 419 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks passed, including Cyrillic and mixed-case suffix verification. The isolated editor integration test passed partial-selection recovery, the `PuntoSwitcher ` repeat-conversion case, clipboard restoration and rejection of unrelated text. Direct validation in LinkedIn and ChatGPT is still outstanding; native-editor tests do not establish browser-editor compatibility.
## Limitations ## Limitations
Image decoding or a normalized image larger than the 128 MiB editor-transfer limit can prevent opening; an error is shown. Existing clipboard, OCR and editor privilege limitations remain. The wheel over screenshot annotations changes size/thickness rather than scrolling. Recovery is attempted once and still depends on the editor processing simulated selection keys. A failed full-text check cancels replacement. Existing clipboard, accessibility and input-context restrictions remain; this patch does not change paste readback verification.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. **Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, development marker, Tesseract installation or language models. Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
Build and package: Build and package:
+1 -1
View File
@@ -1 +1 @@
0b3c4fd1346d0b4eeb73d276193c5d2789fd32986c3cc75384cf489f23629678 KSwitcher-win-x64.zip b80632cc93abadf2a23921c3275b8ff37d98c92b8b583a9621d0c2b589ed6a35 KSwitcher-win-x64.zip
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,20,0 FILEVERSION 0,6,22,0
PRODUCTVERSION 0,6,20,0 PRODUCTVERSION 0,6,22,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.20" VALUE "FileVersion", "0.6.22"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+26 -8
View File
@@ -227,7 +227,8 @@ class Transaction
return selectPrevious(n, [&] { return valid(); }); return selectPrevious(n, [&] { return valid(); });
} }
std::wstring copyRange(size_t count, const std::wstring &wordCandidate, std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
const std::function<bool(const std::wstring &)> &matches) const std::function<bool(const std::wstring &)> &matches,
const std::function<bool(const std::wstring &)> &suffixMatches = {})
{ {
// WebView word boundaries are only candidates; exact verification is mandatory. // WebView word boundaries are only candidates; exact verification is mandatory.
if (needsSelectionDelay(target) && !wordCandidate.empty() && if (needsSelectionDelay(target) && !wordCandidate.empty() &&
@@ -247,7 +248,25 @@ class Transaction
return {}; return {};
Sleep(25); Sleep(25);
} }
return select(count) ? copy() : std::wstring{}; auto text = select(count) ? copy() : std::wstring{};
// Chromium editors can apply only part of a burst of Shift+Left events.
// Extend only a verified suffix; never replace a partial or unrelated range.
if (valid() && !matches(text) && !text.empty() &&
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
caretSteps(text) < count)
{
const auto missing = count - caretSteps(text);
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
for (size_t i = 0; i < missing; ++i)
{
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
return {};
Sleep(25);
}
text = copy();
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
}
return text;
} }
bool paste(const Clip &clip) bool paste(const Clip &clip)
{ {
@@ -1138,7 +1157,9 @@ void App::transform(int command, int snippet)
{ {
text = text =
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate) tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
{ return recent.match(candidate, available, physicalLayout()) >= 0; }); { return recent.match(candidate, available, physicalLayout()) >= 0; },
[&](const std::wstring &candidate)
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
hint = recent.match(text, available, physicalLayout()); hint = recent.match(text, available, physicalLayout());
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" + diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint)); std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
@@ -1155,6 +1176,7 @@ void App::transform(int command, int snippet)
previous->version == version) previous->version == version)
{ {
expected = previous->result; expected = previous->result;
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
text = tx.copyRange(caretSteps(previous->result), previous->result, text = tx.copyRange(caretSteps(previous->result), previous->result,
[&](const std::wstring &candidate) [&](const std::wstring &candidate)
{ return newlines(candidate) == newlines(previous->result); }); { return newlines(candidate) == newlines(previous->result); });
@@ -1202,11 +1224,7 @@ void App::transform(int command, int snippet)
chord(VK_RIGHT); chord(VK_RIGHT);
return; return;
} }
int source = detect(text, available, hint); to = nextConversionLayout(text, available, cycle, hint);
if (source < 0)
source = hint;
auto it = std::find(cycle.begin(), cycle.end(), source);
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
} }
result = convertTo(text, available, to, hint); result = convertTo(text, available, to, hint);
} }
+28
View File
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
} }
return best < 0 ? text : convert(text, layouts[best], to); return best < 0 ? text : convert(text, layouts[best], to);
} }
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
const std::vector<int> &cycle, int hint)
{
if (cycle.empty())
return -1;
int source = detect(text, layouts, hint);
if (source < 0)
source = hint;
auto it = std::find(cycle.begin(), cycle.end(), source);
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
// Compare every candidate with the original input, never an intermediate conversion.
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
for (size_t offset = 0; offset < cycle.size(); ++offset)
{
int target = cycle[(start + offset) % cycle.size()];
if (target != source && convertTo(text, layouts, target, hint) != text)
return target;
}
return cycle[start];
}
std::wstring cycleCase(const std::wstring &text) std::wstring cycleCase(const std::wstring &text)
{ {
if (text == upper(text)) if (text == upper(text))
@@ -459,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
keys.pop_back(); keys.pop_back();
at = now; at = now;
} }
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{
if (text.empty() || text.size() >= keys.size() || overflow)
return -1;
Run tail = *this;
tail.keys = keys.substr(keys.size() - text.size());
return tail.match(text, layouts, physical);
}
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{ {
if (text.size() != keys.size() || overflow) if (text.size() != keys.size() || overflow)
+3
View File
@@ -24,6 +24,8 @@ std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false); std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1); int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint); std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
const std::vector<int> &cycle, int hint);
std::wstring cycleCase(const std::wstring &); std::wstring cycleCase(const std::wstring &);
std::wstring transliterate(const std::wstring &); std::wstring transliterate(const std::wstring &);
struct Snippet struct Snippet
@@ -92,5 +94,6 @@ struct Run
void record(wchar_t, HWND, HKL, ULONGLONG); void record(wchar_t, HWND, HKL, ULONGLONG);
void backspace(HWND, ULONGLONG); void backspace(HWND, ULONGLONG);
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const; int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
}; };
} // namespace ks } // namespace ks
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.20"; inline constexpr wchar_t AppVersion[] = L"0.6.22";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+35
View File
@@ -26,10 +26,17 @@ bool conversionOnly = false;
int pasteBehavior = 0; int pasteBehavior = 0;
int enterPresses = 0; int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0; int copyFailuresRemaining = 0, copyRequests = 0;
int droppedSelectionKeys = 0;
bool temporaryClipboardExcluded = true; bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false; bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{ {
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
(GetKeyState(VK_SHIFT) & 0x8000))
{
--droppedSelectionKeys;
return 0;
}
// This bare EDIT control is our test fixture, not a full editor with // This bare EDIT control is our test fixture, not a full editor with
// clipboard retries. Keep clipboard access on its owning UI thread while // clipboard retries. Keep clipboard access on its owning UI thread while
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot // it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
@@ -205,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break; break;
case 3: case 3:
require(text() == L"Hello World", "case conversion"); require(text() == L"Hello World", "case conversion");
droppedSelectionKeys = 5;
app->transform(ks::Case); app->transform(ks::Case);
break; break;
case 4: case 4:
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result"); require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
prepare(L"OLDghbdtn", false); prepare(L"OLDghbdtn", false);
record(L"ghbdtn"); record(L"ghbdtn");
app->transform(ks::Direct + ru); app->transform(ks::Direct + ru);
@@ -392,6 +401,32 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart, require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
"disabled diagnostics preserves conversion and writes no records"); "disabled diagnostics preserves conversion and writes no records");
ks::setLoggingEnabled(true); ks::setLoggingEnabled(true);
prepare(L"PuntoSwitcher ", true);
app->transform(ks::Direct + ru);
break;
case 44:
app->transform(ks::Direct + en);
break;
case 45:
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
droppedSelectionKeys = 5;
app->transform(ks::Direct + ru);
break;
case 46:
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
"repeat conversion recovers Switcher suffix and preserves full result");
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
require(ks::readClipboard(app->window).text() == sentinel,
"suffix recovery restores original clipboard");
// Derive the actual text from the physical keys, preserving an older prefix.
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
record(L"snipping ");
droppedSelectionKeys = 4;
app->transform(ks::Direct + en);
break;
case 47:
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
finish(); finish();
break; break;
case 30: case 30:
+36
View File
@@ -192,6 +192,26 @@ int main(int argc, char **argv)
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words"); require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words"); require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
std::vector<ks::Layout> layouts{en, ru, uk}; std::vector<ks::Layout> layouts{en, ru, uk};
ks::Run suffixRun;
suffixRun.keys = L"snipping ";
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
"recent Cyrillic suffix matches physical keys across layouts");
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
"recent English suffix matches physical keys");
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
"suffix recovery rejects unrelated, empty and complete ranges");
suffixRun.overflow = true;
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
"suffix recovery rejects overflowed tracking");
suffixRun.overflow = false;
suffixRun.keys = L"LinkedIn ";
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
"ChatGPT partial CamelCase selection matches tracked physical suffix");
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
suffixRun.match(L"лувШт ", layouts, en) < 0,
"CamelCase conversion still requires the complete tracked text after recovery");
ks::AutoCorrector corrector; ks::AutoCorrector corrector;
ks::Settings autoSettings; ks::Settings autoSettings;
require(!autoSettings.autoCorrect, "auto-correction defaults off"); require(!autoSettings.autoCorrect, "auto-correction defaults off");
@@ -337,6 +357,22 @@ int main(int argc, char **argv)
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion"); require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged"); require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion"); require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
"cycle skips unchanged Russian to Ukrainian and wraps to English");
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
"cycle skips unchanged Ukrainian to Russian");
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
"cycle keeps the next language when text changes");
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
"cycle preserves configured order for changed text");
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
"cycle does not use disabled English when all candidates are unchanged");
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
"unchanged digits finish after a bounded cycle");
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
"source outside cycle still skips unchanged candidates");
require(ks::cycleCase(L"hello world") == L"Hello World", "title case"); require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case"); require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case"); require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");