diff --git a/CMakeLists.txt b/CMakeLists.txt index 8263c4c..1b7a88d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.14 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.15 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index da08bec..a9bd918 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,10 @@ thumbnails and source labels aligned with the scaled fonts and spacing. - Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks. - Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion. - Text snippets across layouts, multiline insertion and a verified `{cursor}` position; - `{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo). + `{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes. + For example, `rider .{enter}` inserts `rider .` and presses Enter. + Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved. + `{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo). - Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste; full-text search, pinning, deletion, full text viewing and image previews. - Image thumbnails and text/file icons appear directly in the history list. Only visible rows diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index daae471..266aaa6 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 cbbaf31..7ae83e7 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,17 +1,17 @@ # Latest Windows release -## v0.6.14 — 2026-10-30 +## v0.6.15 — 2026-10-30 ## Changes -- Restore resizing for the automatic-correction settings dialog with an adaptive layout. -- Open the dialog with larger personal-dictionary fields. The three dictionaries share additional width evenly and receive additional height as the window grows. -- Expand exclusion lists and punctuation fields horizontally, and move lower settings and action buttons with the window while preserving spacing. -- Retain scrolling when the dialog must fit a small screen. +- Preserve typing that arrives while a snippet is being expanded. +- Add `{enter}` to press Enter after insertion and cursor placement, with an Insert Enter marker button. Use `{{enter}}` for literal text. +- Suppress automatic correction during snippet expansion and buffered typing, without changing the global setting. Normal typing resumes automatic correction afterward. +- Restore the caret when snippet verification fails and record snippet failures in diagnostic logs. Includes the earlier automatic-correction and log-viewer improvements. Existing modern releases update directly to this version without installing intermediate releases. ## Validation -220 native regression checks, 472 input/UI checks including dictionary sizing and footer placement, screenshot editor and notification registration checks, 45 updater checks and updater progress checks. The resized dialog was also confirmed in the development build by the user. +223 native regression checks, 472 input/UI checks, screenshot editor and notification registration checks, 45 updater checks and updater progress checks. Targeted native integration checks cover continuous snippet typing, clipboard restoration, actual Enter key events, punctuation preservation, suppression of automatic correction and its resumption afterward. The updated development build was also confirmed by the user in Notepad. The broader integration suite showed intermittent failures during earlier interactive runs; it is not claimed as fully passing. **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 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 c73d402..e1ae36b 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -4939b83b2cd641e969107091012fe12340b09d73ec1b7f7292c6809d226e05f5 KSwitcher-win-x64.zip +04229e97b239683db7c113342f7c1e95e6f50b8fcbd1994e7b0920987766b9ab KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 79d29cb..32795b3 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,14,0 - PRODUCTVERSION 0,6,14,0 + FILEVERSION 0,6,15,0 + PRODUCTVERSION 0,6,15,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.14" + VALUE "FileVersion", "0.6.15" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index b6ed949..aea5901 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -12,6 +12,11 @@ namespace ks App *App::current = nullptr; namespace { +ULONG_PTR snippetReplayMarker() +{ + static const char marker{}; + return reinterpret_cast(&marker); +} std::vector> shortcuts(const Settings &s) { std::vector> out{{Toggle, s.toggle}, @@ -566,7 +571,8 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods) return; } run.record(ch, fg, layout, GetTickCount64()); - if (settings.autoCorrect && mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 && + if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() && + mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 && run.keys.size() <= 41 && !busy && !run.overflow) { for (int source = 0; source < (int)layouts.size(); ++source) @@ -619,7 +625,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods) for (auto &trigger : variants) if (keys.size() >= trigger.size() && keys.ends_with(trigger)) { - PostMessageW(window, SnippetMessage, (WPARAM)i, 0); + // Keep subsequent typing out of the selection/paste transaction. + typingTarget = fg; + snippetPending = (int)i; + snippetTyping = true; + busy = true; + if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load())) + { + typingTarget = nullptr; + snippetPending = -1; + snippetTyping = false; + busy = false; + } return; } } @@ -693,12 +710,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods) return true; return false; } -bool App::beginTypingDeferral(HWND target, uint64_t version) +bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet) { std::lock_guard lock(stateMutex); if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased()) return false; typingTarget = target; + snippetTyping = snippet; return true; } void App::replayTyping() @@ -710,7 +728,12 @@ void App::replayTyping() target = typingTarget; typingTarget = nullptr; autoPending = false; + snippetPending = -1; inputs.swap(deferredTyping); + if (snippetTyping) + for (auto &input : inputs) + input.ki.dwExtraInfo = snippetReplayMarker(); + snippetTyping = false; } busy = false; if (inputs.empty()) @@ -826,14 +849,19 @@ void App::transform(int command, int snippet) L" version=" + std::to_wstring(currentVersion) + L" repeat=" + std::to_wstring(repeatAttempts)); } - const bool preDeferred = command == AutoCorrect && autoPending; + const bool preDeferred = (command == AutoCorrect && autoPending) || + (snippet >= 0 && snippetPending == snippet); if (busy.exchange(true) && !preDeferred) { if (manual) diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true); return; } - if (preDeferred) autoPending = false; + if (preDeferred) + { + autoPending = false; + snippetPending = -1; + } Run recent; std::optional previous; Settings config; @@ -904,7 +932,7 @@ void App::transform(int command, int snippet) reason != L"paste-sent-unverified" && reason != L"layout-only" && reason != L"unchanged-expected"); } - } audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to}; + } audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected, hint, to}; try { auto valid = [&] @@ -946,9 +974,9 @@ void App::transform(int command, int snippet) return; } reason = L"clipboard-snapshot"; - if (command == AutoCorrect) + if (command == AutoCorrect || snippet >= 0) { - if (!beginTypingDeferral(target, version)) + if (!beginTypingDeferral(target, version, snippet >= 0)) return; reset.deferred = true; } @@ -1043,13 +1071,11 @@ void App::transform(int command, int snippet) tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size()); if (!tx.select(sn.trigger.size())) return; + autoSelected = true; + reason = L"snippet-verification-mismatch"; text = tx.copy(); if (tail.match(text, available, physicalLayout()) < 0) - { - if (tx.valid()) - chord(VK_RIGHT); return; - } result = parseTemplate(sn.text).text; automatic = true; } @@ -1162,12 +1188,22 @@ void App::transform(int command, int snippet) if (snippet >= 0 && sent && tx.valid()) { auto t = parseTemplate(config.snippets[snippet].text); - if (!t.suffix.empty() && tx.select(caretSteps(t.suffix))) + if (!t.suffix.empty()) { + if (!tx.select(caretSteps(t.suffix))) return; auto suffix = tx.copy(); - if (tx.valid()) - chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT); + if (!tx.valid()) return; + if (newlines(suffix) != newlines(t.suffix)) + { + chord(VK_RIGHT); + return; + } + if (!chord(VK_LEFT)) return; } + // Action markers run after insertion and cursor placement, + // before any physical typing buffered during expansion. + for (int i = 0; i < t.enters && tx.valid(); ++i) + if (!chord(VK_RETURN)) break; } if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text)) { @@ -1517,15 +1553,20 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case AutoCorrectMessage: if (app->autoPending) { - if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp) + if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp) app->transform(AutoCorrect); else app->replayTyping(); } return 0; case SnippetMessage: - if (app->installingUpdate) return 0; - app->transform(0, (int)wp); + if (app->snippetPending == (int)wp) + { + if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp) + app->transform(0, (int)wp); + else + app->replayTyping(); + } return 0; case HistoryMessage: if (app->historyWindow && IsWindowVisible(app->historyWindow)) diff --git a/src/app.h b/src/app.h index 1bd8c27..551f496 100644 --- a/src/app.h +++ b/src/app.h @@ -109,8 +109,10 @@ class App HotkeyHook hotkeyHook; HWND typingTarget{}; bool autoPending{}; + int snippetPending = -1; + bool snippetTyping{}; std::vector deferredTyping; - bool beginTypingDeferral(HWND, uint64_t); + bool beginTypingDeferral(HWND, uint64_t, bool snippet = false); void replayTyping(); static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM); static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM); diff --git a/src/core.cpp b/src/core.cpp index 1378b44..820f94e 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -261,7 +261,17 @@ Template parseTemplate(const std::wstring &text) size_t cursor = std::wstring::npos; for (size_t i = 0; i < text.size();) { - if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0) + if (text.compare(i, 9, L"{{enter}}") == 0) + { + t.text += L"{enter}"; + i += 9; + } + else if (text.compare(i, 7, L"{enter}") == 0) + { + ++t.enters; + i += 7; + } + else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0) { t.text += L"{cursor}"; i += 10; diff --git a/src/core.h b/src/core.h index 5aa566f..521b7e4 100644 --- a/src/core.h +++ b/src/core.h @@ -34,6 +34,7 @@ struct Template { std::wstring text, suffix; int cursors = 0; + int enters = 0; }; Template parseTemplate(const std::wstring &); struct Hotkey diff --git a/src/ui.cpp b/src/ui.cpp index 40dac0e..0c86e24 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1448,9 +1448,9 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) } return 0; } - if (id == 312) + if (id == 312 || id == 313) { - SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}"); + SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}")); return 0; } if (id == IDOK) @@ -1900,8 +1900,9 @@ void showSnippets(App &app) edit(w, 302, 236, 44, 488); label(w, L"Replacement text", 236, 82, 480); edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL); - label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488); + control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36); button(w, L"Insert cursor marker", 312, 236, 430, 190); + button(w, L"Insert Enter marker", 313, 436, 430, 190); button(w, L"Save", IDOK, 510, 478, 100); button(w, L"Cancel", IDCANCEL, 624, 478, 100); fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA)); diff --git a/tests/integration.cpp b/tests/integration.cpp index 51e4bfb..e6ef84e 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -21,6 +21,12 @@ ks::App *app{}; HWND host{}, field{}; ks::Clip saved; bool captured = false, failed = false; +int enterPresses = 0; +LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) +{ + if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses; + return DefSubclassProc(w, message, wp, lp); +} int stage = 0, ru = -1, en = -1; size_t punctuationCase = 0; bool typeDuringCorrection = false, typedDuringCorrection = false; @@ -281,7 +287,73 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) break; case 17: require(text() == punctuationCases.back().first, "undo preserves custom punctuation"); - [[fallthrough]]; + stage = 20; + break; + case 30: + case 32: + case 34: + if (!captured) + { + saved = ks::readClipboard(app->window, 64 * 1024 * 1024); + captured = true; + } + app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel)); + app->settings.autoCorrect = stage == 35; + app->settings.autoLanguages = {L"en", L"ru"}; + app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages); + app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}}; + enterPresses = 0; + ActivateKeyboardLayout(app->layouts[en].handle, 0); + prepare(L"prefix ", false); + { + std::vector inputs; + const std::vector keys = stage == 35 + ? std::vector{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE} + : std::vector{VK_OEM_1, 'R', VK_SPACE, 'X'}; + for (WORD key : keys) + for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)}) + { + auto input = ks::keyboardInput(key, flags); + input.ki.dwExtraInfo = 123; + inputs.push_back(input); + } + require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(), + "snippet trigger and following text submitted without a pause"); + } + break; + case 31: + require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing"); + require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard"); + require(enterPresses == 0, "snippet without Enter marker sends no Enter"); + stage = 32; + break; + case 33: + require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing"); + require(enterPresses == 1, "Enter marker sends an actual Enter key press"); + require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard"); + stage = 34; + break; + case 35: + require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction"); + require(app->settings.autoCorrect, "snippet preserves global auto-correction setting"); + require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled"); + { + std::vector inputs; + for (WORD key : std::wstring(L"GHBDTN ")) + for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)}) + { + auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags); + input.ki.dwExtraInfo = 123; + inputs.push_back(input); + } + require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(), + "normal typing submitted after snippet completes"); + } + break; + case 36: + require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing"); + finish(); + break; case 20: if (!captured) { @@ -353,7 +425,7 @@ 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"); - finish(); + stage = 30; break; } } @@ -477,8 +549,11 @@ int main(int argc, char **argv) CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr); deadline = GetTickCount64() + 30000; + SetWindowSubclass(field, observeEnter, 1, 0); if (argc > 1 && std::string(argv[1]) == "--typing-only") stage = 20; + if (argc > 1 && std::string(argv[1]) == "--snippets-only") + stage = 30; SetTimer(host, 1, 100, nullptr); app->execute(); } diff --git a/tests/tests.cpp b/tests/tests.cpp index a6befb9..4158606 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -301,6 +301,12 @@ int main(int argc, char **argv) require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration"); require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix"); require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor"); + require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." && + ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text"); + require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" && + ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix"); + require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" && + ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal"); require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection"); require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse"); require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");