diff --git a/CMakeLists.txt b/CMakeLists.txt index d1832c5..eb6cede 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.46 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.47 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index 0d17150..5e51db5 100644 --- a/README.md +++ b/README.md @@ -599,8 +599,10 @@ default and require the main **Automatic correction (experimental)** checkbox to - **Use custom replacements**: enter one `typo=correction` pair per line, for example `првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words - are rejected. Each side must contain 1–32 letters of the same supported language, without - spaces or punctuation. Explicit pairs take priority over dictionary recognition, including + are rejected. The typo must contain 1–32 letters. The correction may contain several words + separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including + spaces. Both sides must use the same supported language, without punctuation; leading, + trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including already valid words, and do not require dictionary membership or satisfy the frequency limit. - **Correct spelling typos using dictionaries**: checks unknown words in the current language after no layout correction is found. It accepts exactly one dictionary candidate reachable @@ -616,6 +618,8 @@ ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped. is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches are included in settings export/import and synchronization. Undo restores the original word and suppresses its correction until restart. +When synchronizing phrase replacements, update all participating PCs to v0.6.47 or newer; +older clients reject spaces in replacement pairs. ### Layout correction and shared options diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 9ae7d58..2b6fe1f 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 e756655..1361d8b 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,21 +1,22 @@ # Latest Windows release -## v0.6.46 — 2026-11-05 +## v0.6.47 — 2026-11-05 ## Changes -- Bundle **tools/StayHere.exe** and **tools/StayHere-README.md**. Start the activity utility from **Utilities → StayHere**; it does not start automatically with KSwitcher. -- StayHere starts enabled. Left-click its tray icon to pause/resume or right-click for Exit. While enabled, it prevents automatic display/system sleep and checks inactivity every 15 seconds; after three minutes without input it sends F15. It does not move the mouse, bypass locking, install autostart or directly control Teams presence. Locked/disconnected sessions receive no simulated activity. -- Include StayHere source and reproducible GCC/CMake builds. CLI status/pause/resume/exit parsing tolerates launcher whitespace. Normal updates replace bundled StayHere and documentation while preserving personal utilities. A running old instance continues until exited; relaunch to use the updated executable. -- Retain all v0.6.45 Far snippet, Utilities and tools-folder changes and the v0.6.44 newest-history-selection fix. -- Correct the Far test fixture's working directory: a long temporary path could scroll the command line and invalidate absolute cursor-coordinate assertions. Application snippet behavior is unchanged in this release. +- Custom auto-correction replacements now accept several words on the correction side, for example `мамамила=мама мила`. The source remains a single word of 1–32 letters; the correction supports up to 32 characters including single spaces between words, in the same supported language. +- Preserve initial capitalization, surrounding punctuation, the current layout and Undo when splitting joined words. Subsequent typing is preserved. Updated validation messages and the options hint explain support for spaces. +- Enable **Automatic correction (experimental)** and **Use custom replacements**, enter one `typo=correction` pair per line, then click **OK → Save**. Existing language, minimum-length and exclusion settings still apply; defaults are unchanged. +- Retain bundled StayHere and all earlier fixes. The tray menu remains **Utilities → StayHere**. ## Validation -Passed isolated StayHere start/status/pause/resume/exit checks, Utilities regressions, dictionary equivalence (140,939 words), 105 sync checks, shared wheel tests, 319 native checks, 574 input checks, screenshot UI/save/registration, 54 updater checks and updater progress UI. The updater replaced a running isolated StayHere executable successfully. Final package manifests and downloaded assets are checked before completion. Real-Far spot checks confirmed expansion, caret placement and markers; the uninterrupted full interactive run remains outstanding because unrelated foreground changes cancel it. No additional Far application-code change is shipped in v0.6.46. +Passed 332 native regression checks, 574 input/settings checks and focused auto-correction integration checks, including phrase replacement, capitalization/punctuation, Undo and immediate subsequent typing. Also passed 105 synchronization checks and 54 updater regression checks. Release binaries, package manifest and SHA-256 checksums are verified; published assets are read back before completion. ## 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 its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Find `tools/StayHere.exe` beside the executable actually running. +For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently. -Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep the three KSwitcher executables together; history uses another instance of KSwitcher.exe. StayHere runs independently. The package contains no personal settings, credentials or clipboard history. +When synchronizing phrase replacements across PCs, update each participating PC to v0.6.47 or newer: older versions reject spaces in replacement pairs. + +Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history. Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 04afcd9..f938fea 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -e6d26d59b18559ad226359b35cd403c68326384f8e1918d31b368cbd1110f6b9 KSwitcher-win-x64.zip +c15371f89ea86410267d96c909739b44c207cd76256cd29dc7b5f40b04ac7ffb KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index d2188a8..7fed0a0 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,46,0 - PRODUCTVERSION 0,6,46,0 + FILEVERSION 0,6,47,0 + PRODUCTVERSION 0,6,47,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.46" + VALUE "FileVersion", "0.6.47" END END BLOCK "VarFileInfo" diff --git a/src/autocorrect.cpp b/src/autocorrect.cpp index 81d1810..ab0a412 100644 --- a/src/autocorrect.cpp +++ b/src/autocorrect.cpp @@ -257,7 +257,7 @@ std::optional AutoCorrector::suggest(const std::wstring &typed, { auto replacement = settings.autoReplacements.find(small); if (replacement != settings.autoReplacements.end() && - replacement->second.find_first_not_of(alphabet) == small.npos) + replacement->second.find_first_not_of(alphabet + L" ") == small.npos) return sameLayout(replacement->second); } auto personal = [&](const std::wstring &language, const std::wstring &candidate) diff --git a/src/storage.cpp b/src/storage.cpp index 64ce5ed..dd740e2 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -118,9 +118,11 @@ void validateSettings(const Settings &s) L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"}) valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 && from == lower(from) && to == lower(to) && from != to && - from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos; + from.find_first_not_of(alphabet) == from.npos && + to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos && + to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos; if (!valid) - throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language"); + throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between correction words are allowed."); } for (const auto &[language, words] : s.autoWords) { diff --git a/src/ui.cpp b/src/ui.cpp index 00eff21..3ddb9bf 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1285,7 +1285,7 @@ void showAutoOptions(HWND parent, SettingsView *v) label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525); checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524); check(w, 414, v->draft.autoReplace); - label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525); + label(w, L"One pair per line: typo=correction (same language; correction may contain spaces)", 18, 592, 525); edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN); std::wstring pairs; for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n"; diff --git a/src/update.h b/src/update.h index 3156a8a..65309e2 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.46"; +inline constexpr wchar_t AppVersion[] = L"0.6.47"; 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/input.cpp b/tests/input.cpp index 33584ff..804536f 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -901,7 +901,7 @@ int main(int argc, char **argv) { "personal dictionary field fits options window"); } SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко"); - SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello"); + SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello\r\n Мамамила = Мама мила "); SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0); SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0); SendMessageW(autoOptions,WM_COMMAND,IDOK,0); @@ -923,7 +923,7 @@ int main(int argc, char **argv) { "personal dictionary normalizes case, newlines and duplicate words"); wchar_t pairs[128]{}; GetDlgItemTextW(autoOptions,416,pairs,128); - require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" && + require(std::wstring(pairs)==L"helo=hello\r\nмамамила=мама мила\r\nпрвиіт=привіт\r\n" && SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED && SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED, "typo options and normalized replacement pairs survive reopening"); diff --git a/tests/integration.cpp b/tests/integration.cpp index 1e6c4da..f087558 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -802,14 +802,14 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) case 22: require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma"); app->settings.autoReplace = true; - app->settings.autoReplacements = {{L"helo", L"hello"}}; + app->settings.autoReplacements = {{L"helo", L"hello world"}}; ActivateKeyboardLayout(app->layouts[en].handle, 0); prepare(L"(Helo)! ", false); record(L"(Helo)! "); app->transform(ks::AutoCorrect); break; case 23: - require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital"); + require(text() == L"(Hello world)! ", "explicit phrase pair replaces verified text with punctuation and capital"); app->transform(ks::Undo); break; case 24: @@ -835,7 +835,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) case 27: require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction"); app->settings.autoReplace = true; - app->settings.autoReplacements = {{L"tehh", L"the"}}; + app->settings.autoReplacements = {{L"tehh", L"the word"}}; app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages); prepare(L"", false); { @@ -852,7 +852,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) } break; case 28: - require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing"); + require(text() == L"the word x", "custom phrase replacement through keyboard hook preserves subsequent typing"); app->settings.autoReplace = false; app->settings.autoTypos = false; if (typosOnly) { finish(); break; } diff --git a/tests/tests.cpp b/tests/tests.cpp index 6321ea3..d65719d 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -590,6 +590,12 @@ int main(int argc, char **argv) options.autoNever = {L"hlelo"}; require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs"); options.autoNever.clear(); + options.autoReplacements[L"мамамила"] = L"мама мила"; + correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options); + require(correction && correction->text == L"(Мама мила)! " && correction->target == 2, + "joined words split while preserving capitalization, punctuation and layout"); + edited.reject(L"мамамила ", options); + require(!edited.suggest(L"мамамила ", 2, layouts, options), "undo suppresses phrase replacements"); edited.reject(L"(Hlelo)! ", options); require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements"); options.autoReplace = false; @@ -711,7 +717,7 @@ int main(int argc, char **argv) settings.elevatedAutostart = true; settings.autoCorrect = true; settings.autoReplace = settings.autoTypos = true; - settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}}; + settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}}; settings.autoMaxRank = 10000; settings.autoMinLength = 5; settings.autoPrefixChars = L"(#"; @@ -726,7 +732,13 @@ int main(int argc, char **argv) require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip"); require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements, "typo options and Unicode pairs survive settings roundtrip"); - for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}}) + for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}, + {L"helloworld", L" hello world"}, {L"helloworld", L"hello world "}, + {L"helloworld", L"hello world"}, {L"helloworld", L"hello\tworld"}, + {L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"}, + {L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"}, + {L"helloworld", L"hello Hello"}, {L"helloworld", L""}, + {L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}}) { auto invalid = settings; invalid.autoReplacements = {{pair.first, pair.second}};