diff --git a/CMakeLists.txt b/CMakeLists.txt index 8017c41..7a7402a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.11 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.12 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index ffb12bf..da08bec 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding. The application lives in the notification area. Right-click its icon for Settings, Snippets and history. Double-click the tray icon to open Settings, the bold default menu item. -Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable. +Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable. Repeated launches activate the existing configurator; closing its last window exits that process. Saving applies changes immediately through the resident KSwitcher process. The configurator does not -load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain +load clipboard history, dictionaries or global input hooks. Clipboard history remains in the resident app. Screen capture and all screenshot editors run on demand in **KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same process. Closing the last editor (or cancelling the only selection) exits that process. It does not @@ -291,6 +291,8 @@ to the detected current layout, is treated as expected. `paste-sent-unverified` accepted the paste shortcut; it does not prove that the editor applied the replacement. The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates. Open **View log…** from Settings or the tray menu to read logs inside KSwitcher. +The viewer runs in the configurator process so log loading, painting and pointer interaction do not +block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident. Choose the current or previous log and press **Refresh** to reload. The ListView shows Time, Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled. @@ -363,12 +365,15 @@ Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. En loading sorts them and merges duplicates using the lowest rank. Back up your edited files before replacing dictionaries with a new distribution. -After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local -dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels -pending work. During the replacement itself, key presses and releases are queued and replayed after +On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries. +For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent +key presses and releases; no typing pause is required. Queued input is replayed after clipboard restoration and layout switching, preserving typing order, modifiers and Backspace. If the active window changes, queued typing is not sent to another window and a warning is shown. -It only corrects unknown words when exactly one alternative is a dictionary word. +It only corrects unknown words when the dictionary alternatives agree on the resulting text. +Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching +language in the configured dictionary order (the UI uses English, Ukrainian, Russian). +Different valid spellings remain ambiguous and are left unchanged. **Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation does not count toward this minimum; the maximum word length is 32 letters. Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 0f343e3..95ec5e4 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 f4c2ec1..bdeb6b3 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,16 +1,16 @@ # Latest Windows release -## v0.6.11 — 2026-10-29 +## v0.6.12 — 2026-10-30 ## Changes -- Show an update progress window for manual checks and installation. Downloads display percentage and received/total size; verification, unpacking, installation and restart have distinct status messages. -- Keep the progress window running independently while KSwitcher exits and restarts. After the new application confirms startup, show the installed version and a Done button in the same window, without a separate completion notification. -- Show recovery and installation errors in the same window after the application exits. Background update checks remain quiet. - -The progress UI is provided by the currently installed updater. Updating from v0.6.10 to this release still uses the older interface; subsequent updates use the new window. +- Start automatic layout correction immediately after Space and preserve subsequent typing while the correction runs. +- Accept identical corrected words shared by multiple selected languages; use the selected language order to choose the target layout. Different valid spellings remain ambiguous. +- Run the log viewer in the configurator process, separate from the resident input hooks. Clearing logs remains coordinated with the resident process. +- Keep the native four-column ListView. Resize the table with its window, anchor the footer and Close button, and make Details fill the remaining width. +- Exclude copied diagnostic rows from clipboard history. ## Validation -214 native regression checks, 466 input/UI checks, screenshot editor UI checks, isolated notification registration, 45 updater regression checks, and progress-window checks for download, installation, recovery and completion states. Network tests verify real download progress and release download/checksum validation. Startup acknowledgement tests prevent reporting success for ordinary startup or a missing updater handshake. +Native and input regression suites, screenshot UI and updater checks, log-viewer integration, continuous-typing integration, and isolated configurator/log process checks. Manual development-build checks covered window dragging and automatic Details column width. **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 a7c0f34..7fda9b5 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip +866de2d1d6fe121a7845760519f934bf0bd7820bbd11734a14c454b69a6bb8ad KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index e2191de..28d24b0 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,11,0 - PRODUCTVERSION 0,6,11,0 + FILEVERSION 0,6,12,0 + PRODUCTVERSION 0,6,12,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.11" + VALUE "FileVersion", "0.6.12" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index 3eceeae..6ba7b11 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -248,6 +248,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan registrationWarning = L"Settings were not loaded: " + wide(e.what()); } setLoggingEnabled(settings.writeLog); + if (settings.autoCorrect) + try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); } + catch (const std::exception &e) + { + settings.autoCorrect = false; + registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what()); + } try { history = std::make_unique(data / L"history"); @@ -417,12 +424,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback) void App::applySettings(const Settings &s) { bool autostartChanged = false; + AutoCorrector predictor; validateSettings(s); validateTesseract(s); if (s.autoCorrect) { - AutoCorrector probe; - probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages); + predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages); } if (!registerHotkeys(s)) { @@ -454,6 +461,7 @@ void App::applySettings(const Settings &s) { std::lock_guard lock(stateMutex); settings = s; + autoPredictor = std::move(predictor); setLoggingEnabled(s.writeLog); run.reset(); last.reset(); @@ -517,6 +525,7 @@ void App::resetInput() run.reset(); last.reset(); } +void App::clearLog(bool previous) { clearDiagnosticLog(previous); } void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods) { if (ownKeyboardInput(k)) @@ -557,9 +566,32 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods) return; } run.record(ch, fg, layout, GetTickCount64()); - if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 && + if (settings.autoCorrect && mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 && run.keys.size() <= 41 && !busy && !run.overflow) - PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0); + { + for (int source = 0; source < (int)layouts.size(); ++source) + if (layouts[source].handle == layout) + { + std::wstring predicted; + for (wchar_t key : run.keys) + predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key)); + if (autoPredictor.suggest(predicted, source, layouts, settings)) + { + // Reserve the transaction before the next physical key arrives. + // The delimiter itself passes through; subsequent input is queued. + typingTarget = fg; + autoPending = true; + busy = true; + if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0)) + { + typingTarget = nullptr; + autoPending = false; + busy = false; + } + } + break; + } + } if (busy || settings.snippets.empty()) return; auto keys = lower(run.keys); @@ -677,6 +709,7 @@ void App::replayTyping() std::lock_guard lock(stateMutex); target = typingTarget; typingTarget = nullptr; + autoPending = false; inputs.swap(deferredTyping); } busy = false; @@ -793,12 +826,14 @@ void App::transform(int command, int snippet) L" version=" + std::to_wstring(currentVersion) + L" repeat=" + std::to_wstring(repeatAttempts)); } - if (busy.exchange(true)) + const bool preDeferred = command == AutoCorrect && autoPending; + 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; Run recent; std::optional previous; Settings config; @@ -813,7 +848,7 @@ void App::transform(int command, int snippet) version = inputVersion; } enqueue( - [this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt] + [this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred] { struct BusyReset { @@ -827,7 +862,7 @@ void App::transform(int command, int snippet) else b = false; } - } reset{busy, window}; + } reset{busy, window, preDeferred}; auto start = GetTickCount64(); std::wstring reason = L"cancelled-before-selection"; std::wstring text, result, expected; @@ -1476,9 +1511,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) app->captureHistory(); return 0; case AutoCorrectMessage: - if (app->installingUpdate) return 0; - app->pendingAutoVersion = (uint64_t)wp; - SetTimer(hwnd, 3, 250, nullptr); + if (app->autoPending) + { + if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp) + app->transform(AutoCorrect); + else + app->replayTyping(); + } return 0; case SnippetMessage: if (app->installingUpdate) return 0; @@ -1504,7 +1543,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_COPYDATA: { auto *data = (COPYDATASTRUCT *)lp; - if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply) + if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd) { if (app->installingUpdate) return FALSE; return configurationRequest(*app, (HWND)wp, *data); @@ -1534,13 +1573,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) app->dispatch(20); return 0; } - if (wp == 3) - { - KillTimer(hwnd, 3); - if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion) - app->transform(AutoCorrect); - return 0; - } if (wp == 2) { PostMessageW(hwnd, WM_CLOSE, 0, 0); diff --git a/src/app.h b/src/app.h index efa993d..1bd8c27 100644 --- a/src/app.h +++ b/src/app.h @@ -38,6 +38,7 @@ class App Settings settings; std::unique_ptr history; AutoCorrector autoCorrector; + AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing. std::atomic busy{false}, suspended{false}; std::atomic ownSequence{0}; std::atomic inputVersion{0}; @@ -70,6 +71,7 @@ class App void applySettings(const Settings &); void reloadLayouts(); void clearHistory(); + void clearLog(bool previous); void setHotkeyCapture(HWND); bool isHotkey(DWORD, UINT); void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers); @@ -99,7 +101,6 @@ class App bool (*updateNotifier)(const std::wstring &){}; bool stop{}; UINT taskbarCreated{}; - uint64_t pendingAutoVersion{}; uint64_t transformAttempt{}, previousAttemptVersion{}; ULONGLONG previousAttemptAt{}; HWND previousAttemptWindow{}; @@ -107,6 +108,7 @@ class App std::vector> hookBindings; HotkeyHook hotkeyHook; HWND typingTarget{}; + bool autoPending{}; std::vector deferredTyping; bool beginTypingDeferral(HWND, uint64_t); void replayTyping(); diff --git a/src/autocorrect.cpp b/src/autocorrect.cpp index db64efd..5d8b35c 100644 --- a/src/autocorrect.cpp +++ b/src/autocorrect.cpp @@ -172,7 +172,12 @@ std::optional AutoCorrector::suggest(const std::wstring &typed, if (d.rank(word) || personal(language, word)) return {}; std::optional candidate; - int matches = 0; + std::optional matchedText; + auto priority = [&](int layout) + { + auto language = layoutLanguage(layouts[layout]); + return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin(); + }; for (int i = 0; i < (int)layouts.size(); ++i) { if (i == source || !layouts[i].convertible || @@ -187,9 +192,11 @@ std::optional AutoCorrector::suggest(const std::wstring &typed, int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted); if (!rank) continue; - if (++matches > 1) + if (matchedText && *matchedText != converted) return {}; - if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) + matchedText = converted; + if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) && + (!candidate || priority(i) < priority(candidate->target))) candidate = AutoSuggestion{source, i, prefix + converted + suffix}; } return candidate; diff --git a/src/config_main.cpp b/src/config_main.cpp index 8b213d3..f1ddb4a 100644 --- a/src/config_main.cpp +++ b/src/config_main.cpp @@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp) try { if (wp == 21) showSnippets(*app); + else if (wp == 22) showLogs(*app); else showSettings(*app); } catch (const std::exception &e) @@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp) { if (app->settingsWindow) DestroyWindow(app->settingsWindow); if (app->snippetsWindow) DestroyWindow(app->snippetsWindow); + if (app->logWindow) DestroyWindow(app->logWindow); PostQuitMessage(0); return 0; } @@ -86,6 +88,7 @@ App::~App() { if (settingsWindow) DestroyWindow(settingsWindow); if (snippetsWindow) DestroyWindow(snippetsWindow); + if (logWindow) DestroyWindow(logWindow); if (window) DestroyWindow(window); current = nullptr; } @@ -120,9 +123,9 @@ void App::setHotkeyCapture(HWND field) if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING); } } -void showLogs(App &app) { request(app, ConfigLogs); } +void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast(previous)}); } -int runConfigurator(HINSTANCE instance, bool snippets) +int runConfigurator(HINSTANCE instance, bool snippets, bool logs) { HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton"); if (!singleton) throw std::runtime_error("Cannot create configurator instance lock."); @@ -140,7 +143,7 @@ int runConfigurator(HINSTANCE instance, bool snippets) throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly."); DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); - PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0); + PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0); return 0; } service = FindWindowW(L"KSwitcher.Main", L"KSwitcher"); @@ -169,9 +172,10 @@ int runConfigurator(HINSTANCE instance, bool snippets) 0, 0, 0, 0, nullptr, nullptr, instance, nullptr); if (!app.window) throw std::runtime_error("Cannot create configurator window."); app.settings = decodeSettings(request(app, ConfigRead), app.layouts); - if (snippets) showSnippets(app); else showSettings(app); + setLoggingEnabled(app.settings.writeLog); + if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app); MSG message; - while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0) + while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0) { if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow)) { @@ -180,6 +184,7 @@ int runConfigurator(HINSTANCE instance, bool snippets) } if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue; if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue; + if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue; TranslateMessage(&message); DispatchMessageW(&message); } return 0; @@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) { int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count); bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets"; + bool logs = count > 1 && std::wstring(args[1]) == L"--logs"; LocalFree(args); - result = ks::runConfigurator(instance, snippets); + result = ks::runConfigurator(instance, snippets, logs); } catch (const std::exception &e) { diff --git a/src/configuration.cpp b/src/configuration.cpp index 6be196f..930b1ee 100644 --- a/src/configuration.cpp +++ b/src/configuration.cpp @@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename) auto expected = std::filesystem::path(executable()).parent_path() / filename; return ok && _wcsicmp(path, expected.c_str()) == 0; } -void launchConfiguration(App &app, bool snippets) +void launchConfiguration(App &app, bool snippets, bool logs) { auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe"; if (!std::filesystem::exists(path)) throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package."); - auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L""); + auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L""); STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{}; if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, @@ -80,6 +80,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag case ConfigLogs: PostMessageW(app.window, WM_HOTKEY, 22, 0); break; + case ConfigClearLog: + if (message.cbData != 1 || *static_cast(message.lpData) > 1) + throw std::runtime_error("Invalid log selection."); + app.clearLog(*static_cast(message.lpData) != 0); + break; default: return FALSE; } ok = true; diff --git a/src/configuration.h b/src/configuration.h index 2a6c491..cde33d7 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -5,9 +5,9 @@ namespace ks inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1, ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3, ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5, - ConfigReply = ConfigRead + 6; + ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8; inline constexpr size_t ConfigLimit = 8 * 1024 * 1024; bool configurationPeer(HWND, const wchar_t *filename); -void launchConfiguration(App &, bool snippets = false); +void launchConfiguration(App &, bool snippets = false, bool logs = false); LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &); } diff --git a/src/ui.cpp b/src/ui.cpp index 881343f..20e3473 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -34,6 +34,7 @@ struct WindowLayout bool scrolling{}, arranging{}, history{}; int wheelRemainder{}, horizontalWheelRemainder{}; std::vector> controls; // Original 96-DPI geometry; never rescale rounded pixels. + HWND stretch{}, bottomLeft{}, bottomRight{}; }; WindowLayout *windowLayoutState(HWND w) { @@ -69,8 +70,9 @@ void arrangeWindow(HWND w) layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE); } RECT client{}; GetClientRect(w, &client); - int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0; - int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0; + bool expandable = layout->history || layout->stretch; + int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0; + int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0; struct Move { HWND child; RECT rect; }; std::vector moves; for (const auto &[child, r] : layout->controls) @@ -80,6 +82,13 @@ void arrangeWindow(HWND w) scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)}; next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0); next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0); + if (child == layout->stretch) + { + next.right += extraWidth; + next.bottom += extraHeight; + } + if (child == layout->bottomLeft || child == layout->bottomRight) + OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight); RECT current{}; GetWindowRect(child, ¤t); MapWindowPoints(nullptr, w, reinterpret_cast(¤t), 2); if (!EqualRect(¤t, &next)) moves.push_back({child, next}); @@ -1530,53 +1539,41 @@ void showHistory(App &app, bool visible) SetFocus(GetDlgItem(app.historyWindow, 101)); refreshHistory(app); } +#endif +#ifndef KS_RESIDENT_APP namespace { constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605, LogClear = 606; -LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) +void fitLogDetails(HWND list) { - // Header notifications go to the ListView, not its top-level parent. - if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) && - ((NMHDR *)lp)->code == NM_CUSTOMDRAW) + if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return; + SetPropW(list, L"FittingLogDetails", (HANDLE)1); + RECT client{}; + GetClientRect(list, &client); + int remaining = client.right; + for (int column = 0; column < 3; ++column) + remaining -= ListView_GetColumnWidth(list, column); + int width = std::max(scale(list, 120), remaining); + if (ListView_GetColumnWidth(list, 3) != width) + ListView_SetColumnWidth(list, 3, width); + RemovePropW(list, L"FittingLogDetails"); +} +LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) +{ + auto result = DefSubclassProc(w, msg, wp, lp); + if (msg == WM_SIZE) + fitLogDetails(w); + if (msg == WM_NOTIFY && lp) { - auto *draw = (NMCUSTOMDRAW *)lp; - if (draw->dwDrawStage == CDDS_PREPAINT) - { - FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE)); - return CDRF_NOTIFYITEMDRAW; - } - if (draw->dwDrawStage == CDDS_ITEMPREPAINT) - { - int saved = SaveDC(draw->hdc); - FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE)); - RECT edge = draw->rc; - edge.top = edge.bottom - 1; - FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW)); - edge = draw->rc; - edge.left = edge.right - 1; - FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW)); - wchar_t title[128]{}; - HDITEMW item{}; - item.mask = HDI_TEXT; - item.pszText = title; - item.cchTextMax = 128; - Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item); - auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0); - if (headerFont) - SelectObject(draw->hdc, headerFont); - SetBkMode(draw->hdc, TRANSPARENT); - SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT)); - RECT text = draw->rc; - InflateRect(&text, -scale(w, 6), 0); - DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX); - RestoreDC(draw->hdc, saved); - return CDRF_SKIPDEFAULT; - } + auto *header = reinterpret_cast(lp); + if (header->hdr.hwndFrom == ListView_GetHeader(w) && + (header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) && + header->iItem >= 0 && header->iItem < 3) + fitLogDetails(w); } - if (msg == WM_NCDESTROY) - RemoveWindowSubclass(w, logListProc, 1); - return DefSubclassProc(w, msg, wp, lp); + if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3); + return result; } void copyLogRows(HWND w, App &app) { @@ -1596,7 +1593,11 @@ void copyLogRows(HWND w, App &app) copied += L"\r\n"; } if (!copied.empty()) - app.ownSequence = writeClipboard(app.window, textClip(copied)); + { + auto clip = textClip(copied); + clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}}); + app.ownSequence = writeClipboard(app.window, clip); + } } void refreshLogView(HWND w, App &app) { @@ -1664,6 +1665,7 @@ void refreshLogView(HWND w, App &app) InvalidateRect(list, nullptr, TRUE); if (row) ListView_EnsureVisible(list, row - 1, FALSE); + fitLogDetails(list); value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only." : L"Logging disabled. Showing previously saved entries."); } @@ -1696,7 +1698,7 @@ LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { try { - clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1); + app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1); refreshLogView(w, *app); } catch (const std::exception &e) { error(w, e); } @@ -1758,7 +1760,7 @@ void showLogs(App &app) auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS, LogRows, 16, 82, 868, 454); ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER); - SetWindowSubclass(list, logListProc, 1, 0); + SetWindowSubclass(list, logListResizeProc, 3, 0); const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"}; const int widths[] = {155, 65, 195, 900}; for (int i = 0; i < 4; ++i) @@ -1769,12 +1771,20 @@ void showLogs(App &app) column.cx = scale(w, widths[i]); ListView_InsertColumn(list, i, &column); } - label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720); + auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", + 0, 0, 16, 551, 720, 22); button(w, L"Close", IDCANCEL, 774, 548, 110); + auto *layout = windowLayoutState(w); + layout->stretch = list; + layout->bottomLeft = hint; + layout->bottomRight = GetDlgItem(w, IDCANCEL); + arrangeWindow(w); refreshLogView(w, app); showRequestedWindow(w); activateWindow(w); } +#else +void showLogs(App &app) { launchConfiguration(app, false, true); } #endif #ifndef KS_RESIDENT_APP void showSettings(App &app, bool visible) diff --git a/src/update.h b/src/update.h index 974e36b..81640c0 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.11"; +inline constexpr wchar_t AppVersion[] = L"0.6.12"; 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/configuration.ps1 b/tests/configuration.ps1 index 292ede9..c87f3d3 100644 --- a/tests/configuration.ps1 +++ b/tests/configuration.ps1 @@ -1,4 +1,4 @@ -param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged')) +param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly) $ErrorActionPreference = 'Stop' if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) { throw 'Close KSwitcher and its configurator before this isolated process test.' @@ -38,7 +38,7 @@ $checks=0 function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ } function Window($class) { for($i=0;$i -lt 100;$i++) { - $title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}} + $title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher log'}} $window=[ConfigurationTestUi]::FindWindow($class,$title) if($window -ne [IntPtr]::Zero){return $window} Start-Sleep -Milliseconds 50 @@ -56,6 +56,24 @@ function Launch($file,$argument) { function Click($window,$id) { $null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero) } +function TestLogs { + 'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log') + $config=Launch 'KSwitcher.Config.exe' '--logs' + $logs=Window 'KSwitcher.Logs' + [uint32]$logOwner=0 + $null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner) + Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process' + $second=Launch 'KSwitcher.Config.exe' '--logs' + Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator' + Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused' + Click $logs 603 + Assert (!$resident.HasExited) 'refreshing logs leaves resident running' + Click $logs 606 + for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 } + Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process' + Click $logs 2 + Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator' +} try { New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) { @@ -67,6 +85,13 @@ try { ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8 $resident=Launch 'KSwitcher.exe' '' $main=Window 'KSwitcher.Main' + if ($LogsOnly) { + TestLogs + $null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero) + Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally' + "Passed $checks log process checks" + return + } $config=Launch 'KSwitcher.Config.exe' '' $settings=Window 'KSwitcher.Settings' [uint32]$ownerId=0 @@ -110,6 +135,7 @@ try { $config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive' Click $snippets 2 Assert ($config.WaitForExit(6000)) 'closing last window exits configurator' + TestLogs $config=Launch 'KSwitcher.Config.exe' '' $null=Window 'KSwitcher.Settings' $null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero) diff --git a/tests/integration.cpp b/tests/integration.cpp index 139e343..835dcb1 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -1,4 +1,5 @@ #include "app.h" +#include #include #include #include @@ -315,6 +316,25 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) case 19: require(typedDuringCorrection && text() == L"OLDghbdtn fF", "rejected correction collapses selection before replaying typing in original layout"); + ActivateKeyboardLayout(app->layouts[en].handle, 0); + prepare(L"", false); + app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages); + { + std::vector inputs; + for (WORD key : std::wstring(L"GHBDTN F")) + 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(), + "continuous typing submitted in a single batch without a pause after Space"); + } + stage = 21; + break; + case 21: + require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause"); finish(); break; } diff --git a/tests/tests.cpp b/tests/tests.cpp index 01d0156..00c4bee 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -247,6 +247,14 @@ int main(int argc, char **argv) options.autoLanguages = {L"en", L"uk", L"ru"}; options.cycle = {L"en", L"uk", L"ru"}; supplemental.configure(L"dictionaries", options.autoLanguages); + for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}}) + { + auto shared = supplemental.suggest(typed, 0, layouts, options); + require(shared && shared->text == expected && shared->target == 2, + "identical Russian and Ukrainian spelling uses preferred Ukrainian layout"); + } + require(!supplemental.suggest(L"lsv ", 0, layouts, options), + "different dictionary results remain ambiguous"); auto release = supplemental.suggest(L"релыз ", 1, layouts, options); require(release && release->text == L"реліз " && release->target == 2, "supplemental Ukrainian release word corrects from Russian layout");