Release v0.6.12: improve continuous correction and resize log viewer
This commit is contained in:
+1
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
+7
-7
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip
|
||||
866de2d1d6fe121a7845760519f934bf0bd7820bbd11734a14c454b69a6bb8ad KSwitcher-win-x64.zip
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
+50
-18
@@ -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<History>(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<LastTransform> 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);
|
||||
|
||||
@@ -38,6 +38,7 @@ class App
|
||||
Settings settings;
|
||||
std::unique_ptr<History> history;
|
||||
AutoCorrector autoCorrector;
|
||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<uint64_t> 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<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t);
|
||||
void replayTyping();
|
||||
|
||||
+10
-3
@@ -172,7 +172,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
if (d.rank(word) || personal(language, word))
|
||||
return {};
|
||||
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)
|
||||
{
|
||||
if (i == source || !layouts[i].convertible ||
|
||||
@@ -187,9 +192,11 @@ std::optional<AutoSuggestion> 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;
|
||||
|
||||
+12
-6
@@ -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<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");
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
ok = true;
|
||||
|
||||
+2
-2
@@ -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 &);
|
||||
}
|
||||
|
||||
+56
-46
@@ -34,6 +34,7 @@ struct WindowLayout
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> 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<Move> 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<POINT *>(¤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<NMHEADERW *>(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)
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+28
-2
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <commctrl.h>
|
||||
@@ -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<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");
|
||||
finish();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user