diff --git a/CMakeLists.txt b/CMakeLists.txt index f9a5968..c353b0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.20 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.21 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index b6588a6..f1589fe 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ thumbnails and source labels aligned with the scaled fonts and spacing. ## Implemented - 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. - 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 4283e91..9f773d5 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 df5ab7f..9ed34ea 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,16 +1,16 @@ # Latest Windows release -## v0.6.20 — 2026-10-31 +## v0.6.21 — 2026-10-31 ## 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. -- 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. +- 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. ## Validation -Passed 406 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. Manual testing confirmed image editing from history. +Passed 413 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. ## 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. +Existing clipboard, accessibility and input-context verification limitations remain. Copy retries and paste readback verification are unchanged. **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 ebcea9e..e780623 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -0b3c4fd1346d0b4eeb73d276193c5d2789fd32986c3cc75384cf489f23629678 KSwitcher-win-x64.zip +5207831872af5522063c1c549ba582aaed534096e6e51a394b6be6a705951f3d KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index f99b0f1..d4a5c35 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,20,0 - PRODUCTVERSION 0,6,20,0 + FILEVERSION 0,6,21,0 + PRODUCTVERSION 0,6,21,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.20" + VALUE "FileVersion", "0.6.21" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index f678fd5..5a6ea88 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1202,11 +1202,7 @@ void App::transform(int command, int snippet) chord(VK_RIGHT); return; } - int source = detect(text, available, 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; + to = nextConversionLayout(text, available, cycle, hint); } result = convertTo(text, available, to, hint); } diff --git a/src/core.cpp b/src/core.cpp index 820f94e..e73af51 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector &layo } return best < 0 ? text : convert(text, layouts[best], to); } +int nextConversionLayout(const std::wstring &text, const std::vector &layouts, + const std::vector &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) { if (text == upper(text)) diff --git a/src/core.h b/src/core.h index 521b7e4..3aaa8a9 100644 --- a/src/core.h +++ b/src/core.h @@ -24,6 +24,8 @@ std::wstring upper(std::wstring text); std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false); int detect(const std::wstring &, const std::vector &, int preferred = -1); std::wstring convertTo(const std::wstring &, const std::vector &, int target, int hint); +int nextConversionLayout(const std::wstring &, const std::vector &, + const std::vector &cycle, int hint); std::wstring cycleCase(const std::wstring &); std::wstring transliterate(const std::wstring &); struct Snippet diff --git a/src/update.h b/src/update.h index 4edb351..3b4720a 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.20"; +inline constexpr wchar_t AppVersion[] = L"0.6.21"; 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/tests.cpp b/tests/tests.cpp index b063697..b99ca63 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -337,6 +337,22 @@ int main(int argc, char **argv) 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, 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", "upper case"); require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");