Compare commits

...

3 Commits

18 changed files with 317 additions and 102 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.11 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.14 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+11 -6
View File
@@ -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 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. 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. 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. 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 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 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 **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 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. 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. 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. 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, 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 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. 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 loading sorts them and merges duplicates using the lowest rank. Back up your edited
files before replacing dictionaries with a new distribution. 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 On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
pending work. During the replacement itself, key presses and releases are queued and replayed after 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. 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. 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 **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. 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 Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
Binary file not shown.
+7 -6
View File
@@ -1,16 +1,17 @@
# Latest Windows release # Latest Windows release
## v0.6.11 — 2026-10-29 ## v0.6.14 — 2026-10-30
## Changes ## 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. - Restore resizing for the automatic-correction settings dialog with an adaptive layout.
- 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. - Open the dialog with larger personal-dictionary fields. The three dictionaries share additional width evenly and receive additional height as the window grows.
- Show recovery and installation errors in the same window after the application exits. Background update checks remain quiet. - 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.
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. Includes the earlier automatic-correction and log-viewer improvements. Existing modern releases update directly to this version without installing intermediate releases.
## Validation ## 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. 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.
**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. **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. For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
+1 -1
View File
@@ -1 +1 @@
d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip 4939b83b2cd641e969107091012fe12340b09d73ec1b7f7292c6809d226e05f5 KSwitcher-win-x64.zip
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,11,0 FILEVERSION 0,6,14,0
PRODUCTVERSION 0,6,11,0 PRODUCTVERSION 0,6,14,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.11" VALUE "FileVersion", "0.6.14"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+57 -21
View File
@@ -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()); registrationWarning = L"Settings were not loaded: " + wide(e.what());
} }
setLoggingEnabled(settings.writeLog); 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 try
{ {
history = std::make_unique<History>(data / L"history"); history = std::make_unique<History>(data / L"history");
@@ -417,12 +424,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
void App::applySettings(const Settings &s) void App::applySettings(const Settings &s)
{ {
bool autostartChanged = false; bool autostartChanged = false;
AutoCorrector predictor;
validateSettings(s); validateSettings(s);
validateTesseract(s); validateTesseract(s);
if (s.autoCorrect) if (s.autoCorrect)
{ {
AutoCorrector probe; predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
} }
if (!registerHotkeys(s)) if (!registerHotkeys(s))
{ {
@@ -454,6 +461,7 @@ void App::applySettings(const Settings &s)
{ {
std::lock_guard lock(stateMutex); std::lock_guard lock(stateMutex);
settings = s; settings = s;
autoPredictor = std::move(predictor);
setLoggingEnabled(s.writeLog); setLoggingEnabled(s.writeLog);
run.reset(); run.reset();
last.reset(); last.reset();
@@ -517,6 +525,7 @@ void App::resetInput()
run.reset(); run.reset();
last.reset(); last.reset();
} }
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods) void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{ {
if (ownKeyboardInput(k)) if (ownKeyboardInput(k))
@@ -557,9 +566,32 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
return; return;
} }
run.record(ch, fg, layout, GetTickCount64()); 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) 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()) if (busy || settings.snippets.empty())
return; return;
auto keys = lower(run.keys); auto keys = lower(run.keys);
@@ -677,6 +709,7 @@ void App::replayTyping()
std::lock_guard lock(stateMutex); std::lock_guard lock(stateMutex);
target = typingTarget; target = typingTarget;
typingTarget = nullptr; typingTarget = nullptr;
autoPending = false;
inputs.swap(deferredTyping); inputs.swap(deferredTyping);
} }
busy = false; busy = false;
@@ -793,12 +826,14 @@ void App::transform(int command, int snippet)
L" version=" + std::to_wstring(currentVersion) + L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts)); L" repeat=" + std::to_wstring(repeatAttempts));
} }
if (busy.exchange(true)) const bool preDeferred = command == AutoCorrect && autoPending;
if (busy.exchange(true) && !preDeferred)
{ {
if (manual) if (manual)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true); diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
return; return;
} }
if (preDeferred) autoPending = false;
Run recent; Run recent;
std::optional<LastTransform> previous; std::optional<LastTransform> previous;
Settings config; Settings config;
@@ -813,7 +848,7 @@ void App::transform(int command, int snippet)
version = inputVersion; version = inputVersion;
} }
enqueue( 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 struct BusyReset
{ {
@@ -827,7 +862,7 @@ void App::transform(int command, int snippet)
else else
b = false; b = false;
} }
} reset{busy, window}; } reset{busy, window, preDeferred};
auto start = GetTickCount64(); auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection"; std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected; std::wstring text, result, expected;
@@ -905,6 +940,7 @@ void App::transform(int command, int snippet)
std::wstring predicted; std::wstring predicted;
for (wchar_t key : recent.keys) for (wchar_t key : recent.keys)
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key)); predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
expected = predicted;
if (!autoCorrector.suggest(predicted, hint, available, config) || if (!autoCorrector.suggest(predicted, hint, available, config) ||
!autoCorrectionAllowed(target, config) || !valid()) !autoCorrectionAllowed(target, config) || !valid())
return; return;
@@ -944,9 +980,12 @@ void App::transform(int command, int snippet)
autoSelected = true; autoSelected = true;
Sleep(25); Sleep(25);
text = tx.copy(); text = tx.copy();
// Some editors first select only ") ". Extend by whole-word // Editors may split hj,bnm at the comma. Extend only a verified
// boundaries, then still verify against the complete tracked run. // suffix of the tracked input, and still require a complete match.
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i) for (int i = 0; i < 3 && tx.valid() &&
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
(autoClosingSelection(text, config) ||
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
{ {
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT)) if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
break; break;
@@ -1476,9 +1515,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->captureHistory(); app->captureHistory();
return 0; return 0;
case AutoCorrectMessage: case AutoCorrectMessage:
if (app->installingUpdate) return 0; if (app->autoPending)
app->pendingAutoVersion = (uint64_t)wp; {
SetTimer(hwnd, 3, 250, nullptr); if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
app->transform(AutoCorrect);
else
app->replayTyping();
}
return 0; return 0;
case SnippetMessage: case SnippetMessage:
if (app->installingUpdate) return 0; if (app->installingUpdate) return 0;
@@ -1504,7 +1547,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_COPYDATA: case WM_COPYDATA:
{ {
auto *data = (COPYDATASTRUCT *)lp; 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; if (app->installingUpdate) return FALSE;
return configurationRequest(*app, (HWND)wp, *data); return configurationRequest(*app, (HWND)wp, *data);
@@ -1534,13 +1577,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->dispatch(20); app->dispatch(20);
return 0; 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) if (wp == 2)
{ {
PostMessageW(hwnd, WM_CLOSE, 0, 0); PostMessageW(hwnd, WM_CLOSE, 0, 0);
+3 -1
View File
@@ -38,6 +38,7 @@ class App
Settings settings; Settings settings;
std::unique_ptr<History> history; std::unique_ptr<History> history;
AutoCorrector autoCorrector; AutoCorrector autoCorrector;
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
std::atomic<bool> busy{false}, suspended{false}; std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0}; std::atomic<DWORD> ownSequence{0};
std::atomic<uint64_t> inputVersion{0}; std::atomic<uint64_t> inputVersion{0};
@@ -70,6 +71,7 @@ class App
void applySettings(const Settings &); void applySettings(const Settings &);
void reloadLayouts(); void reloadLayouts();
void clearHistory(); void clearHistory();
void clearLog(bool previous);
void setHotkeyCapture(HWND); void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT); bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers); void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
@@ -99,7 +101,6 @@ class App
bool (*updateNotifier)(const std::wstring &){}; bool (*updateNotifier)(const std::wstring &){};
bool stop{}; bool stop{};
UINT taskbarCreated{}; UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
uint64_t transformAttempt{}, previousAttemptVersion{}; uint64_t transformAttempt{}, previousAttemptVersion{};
ULONGLONG previousAttemptAt{}; ULONGLONG previousAttemptAt{};
HWND previousAttemptWindow{}; HWND previousAttemptWindow{};
@@ -107,6 +108,7 @@ class App
std::vector<std::pair<int, Hotkey>> hookBindings; std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook; HotkeyHook hotkeyHook;
HWND typingTarget{}; HWND typingTarget{};
bool autoPending{};
std::vector<INPUT> deferredTyping; std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t); bool beginTypingDeferral(HWND, uint64_t);
void replayTyping(); void replayTyping();
+15 -4
View File
@@ -152,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
suffix.insert(suffix.begin(), word.back()); suffix.insert(suffix.begin(), word.back());
word.pop_back(); word.pop_back();
} }
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word))) // Punctuation keys can be letters in the target layout (for example ',' -> 'б').
// Validate letters and capitalization after conversion, not before it.
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
suffix.size() > 6 || rejected.contains(lower(word)))
return {}; return {};
for (auto &never : settings.autoNever) for (auto &never : settings.autoNever)
if (lower(never) == lower(word)) if (lower(never) == lower(word))
@@ -172,7 +176,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
if (d.rank(word) || personal(language, word)) if (d.rank(word) || personal(language, word))
return {}; return {};
std::optional<AutoSuggestion> candidate; std::optional<AutoSuggestion> candidate;
int matches = 0; std::optional<std::wstring> 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) for (int i = 0; i < (int)layouts.size(); ++i)
{ {
if (i == source || !layouts[i].convertible || if (i == source || !layouts[i].convertible ||
@@ -187,9 +196,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted); int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
if (!rank) if (!rank)
continue; continue;
if (++matches > 1) if (matchedText && *matchedText != converted)
return {}; 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}; candidate = AutoSuggestion{source, i, prefix + converted + suffix};
} }
return candidate; return candidate;
+12 -6
View File
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
try try
{ {
if (wp == 21) showSnippets(*app); if (wp == 21) showSnippets(*app);
else if (wp == 22) showLogs(*app);
else showSettings(*app); else showSettings(*app);
} }
catch (const std::exception &e) 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->settingsWindow) DestroyWindow(app->settingsWindow);
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow); if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
if (app->logWindow) DestroyWindow(app->logWindow);
PostQuitMessage(0); PostQuitMessage(0);
return 0; return 0;
} }
@@ -86,6 +88,7 @@ App::~App()
{ {
if (settingsWindow) DestroyWindow(settingsWindow); if (settingsWindow) DestroyWindow(settingsWindow);
if (snippetsWindow) DestroyWindow(snippetsWindow); if (snippetsWindow) DestroyWindow(snippetsWindow);
if (logWindow) DestroyWindow(logWindow);
if (window) DestroyWindow(window); if (window) DestroyWindow(window);
current = nullptr; 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); 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<unsigned char>(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"); HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock."); 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."); throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
DWORD pid{}; GetWindowThreadProcessId(window, &pid); DWORD pid{}; GetWindowThreadProcessId(window, &pid);
AllowSetForegroundWindow(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; return 0;
} }
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher"); 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); 0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
if (!app.window) throw std::runtime_error("Cannot create configurator window."); if (!app.window) throw std::runtime_error("Cannot create configurator window.");
app.settings = decodeSettings(request(app, ConfigRead), app.layouts); 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; 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)) 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.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue; if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
TranslateMessage(&message); DispatchMessageW(&message); TranslateMessage(&message); DispatchMessageW(&message);
} }
return 0; return 0;
@@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{ {
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count); int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets"; bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
LocalFree(args); LocalFree(args);
result = ks::runConfigurator(instance, snippets); result = ks::runConfigurator(instance, snippets, logs);
} }
catch (const std::exception &e) catch (const std::exception &e)
{ {
+7 -2
View File
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
auto expected = std::filesystem::path(executable()).parent_path() / filename; auto expected = std::filesystem::path(executable()).parent_path() / filename;
return ok && _wcsicmp(path, expected.c_str()) == 0; 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"; auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
if (!std::filesystem::exists(path)) if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package."); 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)}; STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{}; PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, 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: case ConfigLogs:
PostMessageW(app.window, WM_HOTKEY, 22, 0); PostMessageW(app.window, WM_HOTKEY, 22, 0);
break; break;
case ConfigClearLog:
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
throw std::runtime_error("Invalid log selection.");
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
break;
default: return FALSE; default: return FALSE;
} }
ok = true; ok = true;
+2 -2
View File
@@ -5,9 +5,9 @@ namespace ks
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1, inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3, ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5, 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; inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename); 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 &); LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
} }
+84 -45
View File
@@ -34,6 +34,8 @@ struct WindowLayout
bool scrolling{}, arranging{}, history{}; bool scrolling{}, arranging{}, history{};
int wheelRemainder{}, horizontalWheelRemainder{}; int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels. std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
HWND stretch{}, bottomLeft{}, bottomRight{};
bool autoOptions{};
}; };
WindowLayout *windowLayoutState(HWND w) WindowLayout *windowLayoutState(HWND w)
{ {
@@ -69,8 +71,9 @@ void arrangeWindow(HWND w)
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE); layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
} }
RECT client{}; GetClientRect(w, &client); RECT client{}; GetClientRect(w, &client);
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0; bool expandable = layout->history || layout->stretch || layout->autoOptions;
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0; 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; }; struct Move { HWND child; RECT rect; };
std::vector<Move> moves; std::vector<Move> moves;
for (const auto &[child, r] : layout->controls) for (const auto &[child, r] : layout->controls)
@@ -80,6 +83,35 @@ void arrangeWindow(HWND w)
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)}; 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.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); 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);
if (layout->autoOptions)
{
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
{
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
int leftExtra = extraWidth * column / 3;
OffsetRect(&next, leftExtra, 0);
next.right += extraWidth * (column + 1) / 3 - leftExtra;
if (r.top == 328) next.bottom += extraHeight;
}
else if (r.top == 474 || r.top == 500)
{
bool rightColumn = r.left >= 290;
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
}
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
OffsetRect(&next, extraWidth, 0);
else if (r.right - r.left >= 500)
next.right += extraWidth;
}
RECT current{}; GetWindowRect(child, &current); RECT current{}; GetWindowRect(child, &current);
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(&current), 2); MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(&current), 2);
if (!EqualRect(&current, &next)) moves.push_back({child, next}); if (!EqualRect(&current, &next)) moves.push_back({child, next});
@@ -1064,6 +1096,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
{ {
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc, HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
560, 680); 560, 680);
windowLayoutState(w)->autoOptions = true;
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent); SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v}); SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525); label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
@@ -1104,6 +1137,11 @@ void showAutoOptions(HWND parent, SettingsView *v)
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525); label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
button(w, L"OK", IDOK, 326, 632, 100); button(w, L"OK", IDOK, 326, 632, 100);
button(w, L"Cancel", IDCANCEL, 440, 632, 100); button(w, L"Cancel", IDCANCEL, 440, 632, 100);
RECT initial{};
GetWindowRect(w, &initial);
initial.right += scale(w, 200);
initial.bottom += scale(w, 140);
fitToMonitor(w, initial);
EnableWindow(parent, FALSE); EnableWindow(parent, FALSE);
showRequestedWindow(w); showRequestedWindow(w);
SetForegroundWindow(w); SetForegroundWindow(w);
@@ -1530,53 +1568,41 @@ void showHistory(App &app, bool visible)
SetFocus(GetDlgItem(app.historyWindow, 101)); SetFocus(GetDlgItem(app.historyWindow, 101));
refreshHistory(app); refreshHistory(app);
} }
#endif
#ifndef KS_RESIDENT_APP
namespace namespace
{ {
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605, constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606; 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 (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) && SetPropW(list, L"FittingLogDetails", (HANDLE)1);
((NMHDR *)lp)->code == NM_CUSTOMDRAW) RECT client{};
{ GetClientRect(list, &client);
auto *draw = (NMCUSTOMDRAW *)lp; int remaining = client.right;
if (draw->dwDrawStage == CDDS_PREPAINT) for (int column = 0; column < 3; ++column)
{ remaining -= ListView_GetColumnWidth(list, column);
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE)); int width = std::max(scale(list, 120), remaining);
return CDRF_NOTIFYITEMDRAW; if (ListView_GetColumnWidth(list, 3) != width)
ListView_SetColumnWidth(list, 3, width);
RemovePropW(list, L"FittingLogDetails");
} }
if (draw->dwDrawStage == CDDS_ITEMPREPAINT) LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{ {
int saved = SaveDC(draw->hdc); auto result = DefSubclassProc(w, msg, wp, lp);
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE)); if (msg == WM_SIZE)
RECT edge = draw->rc; fitLogDetails(w);
edge.top = edge.bottom - 1; if (msg == WM_NOTIFY && lp)
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW)); {
edge = draw->rc; auto *header = reinterpret_cast<NMHEADERW *>(lp);
edge.left = edge.right - 1; if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW)); (header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
wchar_t title[128]{}; header->iItem >= 0 && header->iItem < 3)
HDITEMW item{}; fitLogDetails(w);
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;
} }
} if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
if (msg == WM_NCDESTROY) return result;
RemoveWindowSubclass(w, logListProc, 1);
return DefSubclassProc(w, msg, wp, lp);
} }
void copyLogRows(HWND w, App &app) void copyLogRows(HWND w, App &app)
{ {
@@ -1596,7 +1622,11 @@ void copyLogRows(HWND w, App &app)
copied += L"\r\n"; copied += L"\r\n";
} }
if (!copied.empty()) 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) void refreshLogView(HWND w, App &app)
{ {
@@ -1664,6 +1694,7 @@ void refreshLogView(HWND w, App &app)
InvalidateRect(list, nullptr, TRUE); InvalidateRect(list, nullptr, TRUE);
if (row) if (row)
ListView_EnsureVisible(list, row - 1, FALSE); ListView_EnsureVisible(list, row - 1, FALSE);
fitLogDetails(list);
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only." value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
: L"Logging disabled. Showing previously saved entries."); : L"Logging disabled. Showing previously saved entries.");
} }
@@ -1696,7 +1727,7 @@ LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
try try
{ {
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1); app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
refreshLogView(w, *app); refreshLogView(w, *app);
} }
catch (const std::exception &e) { error(w, e); } catch (const std::exception &e) { error(w, e); }
@@ -1758,7 +1789,7 @@ void showLogs(App &app)
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS, auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
LogRows, 16, 82, 868, 454); LogRows, 16, 82, 868, 454);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER); 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 wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
const int widths[] = {155, 65, 195, 900}; const int widths[] = {155, 65, 195, 900};
for (int i = 0; i < 4; ++i) for (int i = 0; i < 4; ++i)
@@ -1769,12 +1800,20 @@ void showLogs(App &app)
column.cx = scale(w, widths[i]); column.cx = scale(w, widths[i]);
ListView_InsertColumn(list, i, &column); 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); 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); refreshLogView(w, app);
showRequestedWindow(w); showRequestedWindow(w);
activateWindow(w); activateWindow(w);
} }
#else
void showLogs(App &app) { launchConfiguration(app, false, true); }
#endif #endif
#ifndef KS_RESIDENT_APP #ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible) void showSettings(App &app, bool visible)
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.11"; inline constexpr wchar_t AppVersion[] = L"0.6.14";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+28 -2
View File
@@ -1,4 +1,4 @@
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged')) param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) { if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
throw 'Close KSwitcher and its configurator before this isolated process test.' 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 Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
function Window($class) { function Window($class) {
for($i=0;$i -lt 100;$i++) { 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) $window=[ConfigurationTestUi]::FindWindow($class,$title)
if($window -ne [IntPtr]::Zero){return $window} if($window -ne [IntPtr]::Zero){return $window}
Start-Sleep -Milliseconds 50 Start-Sleep -Milliseconds 50
@@ -56,6 +56,24 @@ function Launch($file,$argument) {
function Click($window,$id) { function Click($window,$id) {
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero) $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 { try {
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) { foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
@@ -67,6 +85,13 @@ try {
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8 ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
$resident=Launch 'KSwitcher.exe' '' $resident=Launch 'KSwitcher.exe' ''
$main=Window 'KSwitcher.Main' $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' '' $config=Launch 'KSwitcher.Config.exe' ''
$settings=Window 'KSwitcher.Settings' $settings=Window 'KSwitcher.Settings'
[uint32]$ownerId=0 [uint32]$ownerId=0
@@ -110,6 +135,7 @@ try {
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive' $config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
Click $snippets 2 Click $snippets 2
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator' Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
TestLogs
$config=Launch 'KSwitcher.Config.exe' '' $config=Launch 'KSwitcher.Config.exe' ''
$null=Window 'KSwitcher.Settings' $null=Window 'KSwitcher.Settings'
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero) $null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
+29
View File
@@ -598,6 +598,35 @@ int main(int argc, char **argv) {
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr); auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
require(autoOptions!=nullptr,"auto-correction options open"); require(autoOptions!=nullptr,"auto-correction options open");
ShowWindow(autoOptions,SW_HIDE); ShowWindow(autoOptions,SW_HIDE);
{
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
"auto-correction dialog supports resizing");
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
GetWindowRect(autoOptions,&original);
// Use a client size above the scrolling baseline at every DPI.
UINT dpi=GetDpiForWindow(autoOptions);
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
for(int i=0;i<3;++i) {
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
"dictionary columns share extra width and receive extra height");
}
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
"auto-correction buttons follow bottom-right corner");
require(after[0].right<after[1].left && after[1].right<after[2].left &&
after[2].bottom<buttonAfter.top,
"resized dictionary fields and footer do not overlap");
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
}
for(int id:{411,412,413}) { for(int id:{411,412,413}) {
RECT control{},client{}; RECT control{},client{};
GetWindowRect(GetDlgItem(autoOptions,id),&control); GetWindowRect(GetDlgItem(autoOptions,id),&control);
+38
View File
@@ -1,4 +1,5 @@
#include "app.h" #include "app.h"
#include <algorithm>
#include <iostream> #include <iostream>
#include <fstream> #include <fstream>
#include <commctrl.h> #include <commctrl.h>
@@ -315,6 +316,43 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case 19: case 19:
require(typedDuringCorrection && text() == L"OLDghbdtn fF", require(typedDuringCorrection && text() == L"OLDghbdtn fF",
"rejected correction collapses selection before replaying typing in original layout"); "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<INPUT> 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");
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
return ks::layoutLanguage(layout) == L"uk";
});
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
app->settings.autoLanguages = {L"en", L"uk"};
app->settings.cycle = {app->layouts[en].id, uk->id};
app->settings.autoWords[L"uk"] = {L"робить"};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"hj,bnm ", false);
record(L"hj,bnm ");
app->transform(ks::AutoCorrect);
stage = 22;
break;
}
case 22:
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
finish(); finish();
break; break;
} }
+17
View File
@@ -247,6 +247,14 @@ int main(int argc, char **argv)
options.autoLanguages = {L"en", L"uk", L"ru"}; options.autoLanguages = {L"en", L"uk", L"ru"};
options.cycle = {L"en", L"uk", L"ru"}; options.cycle = {L"en", L"uk", L"ru"};
supplemental.configure(L"dictionaries", options.autoLanguages); 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); auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
require(release && release->text == L"реліз " && release->target == 2, require(release && release->text == L"реліз " && release->target == 2,
"supplemental Ukrainian release word corrects from Russian layout"); "supplemental Ukrainian release word corrects from Russian layout");
@@ -268,6 +276,15 @@ int main(int argc, char **argv)
require(!supplemental.suggest(L"релызик ", 1, layouts, options), require(!supplemental.suggest(L"релызик ", 1, layouts, options),
"never-correct list takes precedence over personal word"); "never-correct list takes precedence over personal word");
options.autoNever.clear(); options.autoNever.clear();
options.autoWords[L"uk"].push_back(L"робить");
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
"personal Ukrainian word accepts a punctuation key that maps to a letter");
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
"punctuation-key correction still rejects digits");
options.autoMinLength = 1;
require(!supplemental.suggest(L"ye ", 0, layouts, options),
"known English ye remains protected at minimum length one");
options.cycle = {L"en", L"ru"}; options.cycle = {L"en", L"ru"};
require(!supplemental.suggest(L"релызик ", 1, layouts, options), require(!supplemental.suggest(L"релызик ", 1, layouts, options),
"personal dictionary respects disabled target layouts"); "personal dictionary respects disabled target layouts");