Add configurable offline layout correction and latest Windows release

This commit is contained in:
2026-10-27 23:50:04 +02:00
parent f683e4178c
commit f1af998029
25 changed files with 142458 additions and 27 deletions
+103 -9
View File
@@ -314,6 +314,11 @@ void App::applySettings(const Settings &s)
{
bool autostartChanged = false;
validateSettings(s);
if (s.autoCorrect)
{
AutoCorrector probe;
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
}
if (!registerHotkeys(s))
{
registerHotkeys(settings, true);
@@ -348,6 +353,8 @@ void App::applySettings(const Settings &s)
last.reset();
++inputVersion;
}
// Clear on the worker so the next check reloads edits to dictionary files.
enqueue([this] { autoCorrector.configure({}, {}); });
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
}
@@ -435,6 +442,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
return;
}
run.record(ch, fg, layout, GetTickCount64());
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
run.keys.size() <= 41 && !busy && !run.overflow)
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
if (busy || settings.snippets.empty())
return;
auto keys = lower(run.keys);
@@ -611,6 +621,8 @@ void App::transform(int command, int snippet)
{ return GetForegroundWindow() == target && inputVersion.load() == version; };
for (int i = 0; i < 8 && !modifiersReleased(); ++i)
Sleep(10);
if (!modifiersReleased() && command == AutoCorrect)
return;
if (!modifiersReleased())
releaseModifiers();
if (!valid())
@@ -624,9 +636,24 @@ void App::transform(int command, int snippet)
return;
if (to >= 0 && !available[to].convertible)
{
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)available[to].handle);
switchWindowLayout(target, available[to].handle, valid);
return;
}
if (command == AutoCorrect)
{
if (!config.autoCorrect || recent.window != target || recent.overflow ||
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
return;
autoCorrector.configure(std::filesystem::path(executable()).parent_path() /
L"dictionaries",
config.autoLanguages);
std::wstring predicted;
for (wchar_t key : recent.keys)
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
if (!autoCorrector.suggest(predicted, hint, available, config) ||
!autoCorrectionAllowed(target, config) || !valid())
return;
}
Transaction tx(*this, target, version);
log(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
@@ -634,7 +661,36 @@ void App::transform(int command, int snippet)
std::to_wstring(recent.window == target));
std::wstring text, result;
bool automatic = false;
if (command == Undo)
if (command == AutoCorrect)
{
// The editor must select the complete previous word. Unlike manual
// conversion, automatic correction must never fall back to a suffix.
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
return;
Sleep(25);
text = tx.copy();
// Some editors first select only ") ". Extend by whole-word
// boundaries, then still verify against the complete tracked run.
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
{
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
break;
Sleep(25);
text = tx.copy();
}
hint = matchAutoInput(recent, text, available, physicalLayout(), config);
auto suggestion = autoCorrector.suggest(text, hint, available, config);
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
{
if (tx.valid())
chord(VK_RIGHT);
return;
}
to = suggestion->target;
result = suggestion->text;
automatic = true;
}
else if (command == Undo)
{
if (!previous || previous->window != target || previous->version != version ||
GetTickCount64() - previous->at > 120000)
@@ -649,6 +705,10 @@ void App::transform(int command, int snippet)
return;
}
result = previous->original;
if (previous->autoCorrected)
{
to = previous->sourceLayout;
}
automatic = true;
}
else if (snippet >= 0)
@@ -710,10 +770,10 @@ void App::transform(int command, int snippet)
if (text.empty())
{
if (to >= 0 && config.switchLayout)
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)available[to].handle);
switchWindowLayout(target, available[to].handle, valid);
return;
}
if (command != Undo && snippet < 0)
if (command != Undo && command != AutoCorrect && snippet < 0)
{
if (command == Case)
result = cycleCase(text);
@@ -750,6 +810,8 @@ void App::transform(int command, int snippet)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
chord(VK_RIGHT);
if (sent && command == Undo && previous && previous->autoCorrected)
autoCorrector.reject(previous->original, config);
if (tx.valid() && (sent || automatic))
{
std::lock_guard lock(stateMutex);
@@ -757,8 +819,8 @@ void App::transform(int command, int snippet)
if (command == Undo || snippet >= 0)
last.reset();
else
last = LastTransform{target, text, result, to >= 0 ? to : hint,
GetTickCount64(), version};
last = LastTransform{target, text, result, to >= 0 ? to : hint,
GetTickCount64(), version, hint, command == AutoCorrect};
}
if (snippet >= 0 && sent && tx.valid())
{
@@ -770,8 +832,11 @@ void App::transform(int command, int snippet)
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
}
}
if (to >= 0 && config.switchLayout && tx.valid())
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)available[to].handle);
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
{
bool switched = switchWindowLayout(target, available[to].handle, valid);
log(L"Layout switch confirmed=" + std::to_wstring(switched));
}
log(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
}
catch (const std::exception &e)
@@ -1002,12 +1067,20 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case TrayMessage:
if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu();
else if (LOWORD(lp) == WM_LBUTTONDBLCLK || LOWORD(lp) == NIN_KEYSELECT)
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
// The shell still owns the mouse gesture on the second button-down.
// Activate after release, so the tray cannot take focus back on button-up.
SetTimer(hwnd, 4, 50, nullptr);
else if (LOWORD(lp) == NIN_KEYSELECT)
app->dispatch(20); // Settings is the tray's default action.
return 0;
case WM_CLIPBOARDUPDATE:
app->captureHistory();
return 0;
case AutoCorrectMessage:
app->pendingAutoVersion = (uint64_t)wp;
SetTimer(hwnd, 3, 250, nullptr);
return 0;
case SnippetMessage:
app->transform(0, (int)wp);
return 0;
@@ -1040,6 +1113,21 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_TIMER:
if (wp == 4)
{
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
return 0;
KillTimer(hwnd, 4);
app->dispatch(20);
return 0;
}
if (wp == 3)
{
KillTimer(hwnd, 3);
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
if (wp == 2)
{
PostMessageW(hwnd, WM_CLOSE, 0, 0);
@@ -1127,6 +1215,12 @@ int App::execute()
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
{
if (settingsWindow && !IsWindowEnabled(settingsWindow))
{
HWND popup = GetLastActivePopup(settingsWindow);
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
continue;
}
if (settingsWindow && IsDialogMessageW(settingsWindow, &msg))
continue;
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))