diff --git a/CMakeLists.txt b/CMakeLists.txt index c353b0f..f72321b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.21 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.22 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index f1589fe..1b3417f 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ thumbnails and source labels aligned with the scaled fonts and spacing. - 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). - 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. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 9f773d5..97a5195 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 9ed34ea..f32a66e 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,16 +1,17 @@ # Latest Windows release -## v0.6.21 — 2026-10-31 +## v0.6.22 — 2026-10-31 ## Changes -- Next layout skips enabled layouts that leave the text unchanged and selects the next layout that changes it. Shared Russian/Ukrainian text such as `рф` and `шььш` becomes `ha` and `immi` when English follows in the cycle. -- Configured order is preserved. If no enabled convertible layout changes the text, it remains unchanged and the operation advances one layout. Direct layout shortcuts retain their existing behavior. +- 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. +- 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 `. +- 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. ## Validation -Passed 413 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. +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 -Existing clipboard, accessibility and input-context verification limitations remain. Copy retries and paste readback verification are unchanged. +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. For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index e780623..fe0b1f4 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -5207831872af5522063c1c549ba582aaed534096e6e51a394b6be6a705951f3d KSwitcher-win-x64.zip +b80632cc93abadf2a23921c3275b8ff37d98c92b8b583a9621d0c2b589ed6a35 KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index d4a5c35..ba6b709 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,6,21,0 - PRODUCTVERSION 0,6,21,0 + FILEVERSION 0,6,22,0 + PRODUCTVERSION 0,6,22,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -20,7 +20,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.6.21" + VALUE "FileVersion", "0.6.22" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index 5a6ea88..168cda6 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -227,7 +227,8 @@ class Transaction return selectPrevious(n, [&] { return valid(); }); } std::wstring copyRange(size_t count, const std::wstring &wordCandidate, - const std::function &matches) + const std::function &matches, + const std::function &suffixMatches = {}) { // WebView word boundaries are only candidates; exact verification is mandatory. if (needsSelectionDelay(target) && !wordCandidate.empty() && @@ -247,7 +248,25 @@ class Transaction return {}; 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) { @@ -1138,7 +1157,9 @@ void App::transform(int command, int snippet) { text = 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()); diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" + 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) { expected = previous->result; + diagnosticEvent(L"Previous result expected=" + diagnosticText(expected)); text = tx.copyRange(caretSteps(previous->result), previous->result, [&](const std::wstring &candidate) { return newlines(candidate) == newlines(previous->result); }); diff --git a/src/core.cpp b/src/core.cpp index e73af51..25d12df 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -479,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now) keys.pop_back(); at = now; } +int Run::matchSuffix(const std::wstring &text, const std::vector &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 &layouts, const Layout &physical) const { if (text.size() != keys.size() || overflow) diff --git a/src/core.h b/src/core.h index 3aaa8a9..affc87a 100644 --- a/src/core.h +++ b/src/core.h @@ -94,5 +94,6 @@ struct Run void record(wchar_t, HWND, HKL, ULONGLONG); void backspace(HWND, ULONGLONG); int match(const std::wstring &, const std::vector &, const Layout &physical) const; + int matchSuffix(const std::wstring &, const std::vector &, const Layout &physical) const; }; } // namespace ks diff --git a/src/update.h b/src/update.h index 3b4720a..ab4ce07 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.21"; +inline constexpr wchar_t AppVersion[] = L"0.6.22"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; diff --git a/tests/integration.cpp b/tests/integration.cpp index 1d6c080..6c1e68a 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -26,10 +26,17 @@ bool conversionOnly = false; int pasteBehavior = 0; int enterPresses = 0; int copyFailuresRemaining = 0, copyRequests = 0; +int droppedSelectionKeys = 0; bool temporaryClipboardExcluded = true; bool clipboardAccessTimedOut = false; 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 // clipboard retries. Keep clipboard access on its owning UI thread while // 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; case 3: require(text() == L"Hello World", "case conversion"); + droppedSelectionKeys = 5; app->transform(ks::Case); break; case 4: 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); record(L"ghbdtn"); 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, "disabled diagnostics preserves conversion and writes no records"); 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(); break; case 30: diff --git a/tests/tests.cpp b/tests/tests.cpp index b99ca63..b3a3cce 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -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"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words"); std::vector 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::Settings autoSettings; require(!autoSettings.autoCorrect, "auto-correction defaults off");