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))
+8 -1
View File
@@ -1,4 +1,5 @@
#pragma once
#include "autocorrect.h"
#include "storage.h"
#include <atomic>
#include <condition_variable>
@@ -11,7 +12,9 @@ namespace ks
{
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
SnippetMessage = WM_APP + 4;
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, Direct = 100;
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
Direct = 100;
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
{
@@ -20,6 +23,8 @@ struct LastTransform
int layout = -1;
ULONGLONG at{};
uint64_t version{};
int sourceLayout = -1;
bool autoCorrected = false;
};
class App
{
@@ -30,6 +35,7 @@ class App
std::vector<Layout> layouts;
Settings settings;
std::unique_ptr<History> history;
AutoCorrector autoCorrector;
std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0};
std::atomic<uint64_t> inputVersion{0};
@@ -70,6 +76,7 @@ class App
std::thread worker;
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook;
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
+241
View File
@@ -0,0 +1,241 @@
#include "autocorrect.h"
#include <algorithm>
#include <charconv>
#include <uiautomation.h>
namespace ks
{
static bool openingDelimiter(wchar_t c, const Settings &settings)
{
return settings.autoPrefixChars.find(c) != std::wstring::npos;
}
static bool trailingDelimiter(wchar_t c, const Settings &settings)
{
return c == L' ' || settings.autoSuffixChars.find(c) != std::wstring::npos;
}
bool autoClosingSelection(const std::wstring &text, const Settings &settings)
{
return !text.empty() && text.size() <= 6 &&
std::all_of(text.begin(), text.end(), [&](wchar_t c) { return trailingDelimiter(c, settings); }) &&
text.find_first_not_of(L' ') != text.npos;
}
std::wstring layoutLanguage(const Layout &layout)
{
if (layout.id == L"en" || layout.id == L"uk" || layout.id == L"ru")
return layout.id;
switch (PRIMARYLANGID(LOWORD((uintptr_t)layout.handle)))
{
case LANG_ENGLISH:
return L"en";
case LANG_UKRAINIAN:
return L"uk";
case LANG_RUSSIAN:
return L"ru";
default:
return {};
}
}
bool safeAutoWord(const std::wstring &word, int minimumLength)
{
if (minimumLength < 1 || minimumLength > 32 || word.size() < (size_t)minimumLength || word.size() > 32 ||
!std::all_of(word.begin(), word.end(), letter))
return false;
auto small = lower(word);
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
}
std::string_view AutoCorrector::Dictionary::word(size_t index) const
{
auto start = offsets[index];
auto end = bytes.find('\t', start);
return std::string_view(bytes).substr(start, end - start);
}
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
{
auto raw = readFile(path, 2 * 1024 * 1024);
bytes.assign(raw.begin(), raw.end());
for (size_t start = 0; start < bytes.size();)
{
auto end = bytes.find('\n', start);
if (end == bytes.npos)
end = bytes.size();
auto tab = bytes.find('\t', start);
if (tab == bytes.npos || tab >= end || tab == start || tab - start > 160)
throw std::runtime_error("Invalid auto-correction dictionary");
int rank{};
auto rankEnd = end > tab + 1 && bytes[end - 1] == '\r' ? end - 1 : end;
auto parse = std::from_chars(bytes.data() + tab + 1, bytes.data() + rankEnd, rank);
if (parse.ec != std::errc() || parse.ptr != bytes.data() + rankEnd || rank <= 0)
throw std::runtime_error("Invalid dictionary rank");
offsets.push_back((uint32_t)start);
start = end + 1;
}
if (offsets.empty())
throw std::runtime_error("Empty auto-correction dictionary");
for (size_t i = 1; i < offsets.size(); ++i)
if (word(i - 1) >= word(i))
throw std::runtime_error("Dictionary must be uniquely sorted");
offsets.shrink_to_fit();
bytes.shrink_to_fit();
}
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
{
auto key = utf8(lower(text));
size_t begin = 0, end = offsets.size();
while (begin < end)
{
auto mid = begin + (end - begin) / 2;
if (word(mid) < key)
begin = mid + 1;
else
end = mid;
}
if (begin == offsets.size() || word(begin) != key)
return 0;
auto start = offsets[begin] + word(begin).size() + 1;
int rank{};
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
return rank;
}
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
{
if (languages == wanted)
return;
std::map<std::wstring, Dictionary> next;
for (auto &lang : wanted)
{
if (lang != L"en" && lang != L"uk" && lang != L"ru")
throw std::runtime_error("Unsupported dictionary language");
next.emplace(lang, Dictionary(folder / (lang + L".txt")));
}
dictionaries = std::move(next);
languages = wanted;
}
void AutoCorrector::reject(std::wstring word, const Settings &settings)
{
while (!word.empty() && openingDelimiter(word.front(), settings))
word.erase(word.begin());
while (!word.empty() && trailingDelimiter(word.back(), settings))
word.pop_back();
if (rejected.size() >= 128)
rejected.clear();
rejected.insert(lower(word));
}
size_t AutoCorrector::memoryBytes() const
{
size_t bytes = 0;
for (auto &[language, d] : dictionaries)
bytes += d.bytes.capacity() + d.offsets.capacity() * sizeof(uint32_t);
return bytes;
}
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
const std::vector<Layout> &layouts,
const Settings &settings) const
{
if (source < 0 || source >= (int)layouts.size() || !layouts[source].convertible)
return {};
std::wstring word = typed, suffix, prefix;
while (!word.empty() && openingDelimiter(word.front(), settings) && prefix.size() < 3)
{
prefix += word.front();
word.erase(word.begin());
}
while (!word.empty() && trailingDelimiter(word.back(), settings))
{
suffix.insert(suffix.begin(), word.back());
word.pop_back();
}
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
return {};
for (auto &never : settings.autoNever)
if (lower(never) == lower(word))
return {};
auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage))
return {};
// Valid words from either selected language are never changed.
for (auto &[language, d] : dictionaries)
if (d.rank(word))
return {};
std::optional<AutoSuggestion> candidate;
int matches = 0;
for (int i = 0; i < (int)layouts.size(); ++i)
{
if (i == source || !layouts[i].convertible ||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[i].id) == settings.cycle.end())
continue;
auto dictionary = dictionaries.find(layoutLanguage(layouts[i]));
if (dictionary == dictionaries.end())
continue;
auto converted = convert(word, layouts[source], layouts[i], true);
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
continue;
int rank = dictionary->second.rank(converted);
if (!rank)
continue;
if (++matches > 1)
return {};
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
}
return candidate;
}
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
const Layout &physical, const Settings &settings)
{
int full = run.match(text, layouts, physical);
if (full >= 0 || run.overflow || text.empty())
return full;
// Ctrl+Shift+Left may omit opening delimiters. Only these delimiters may
// be omitted; never accept an arbitrary matching suffix of a longer word.
for (int i = 0; i < (int)layouts.size(); ++i)
{
if (!layouts[i].convertible)
continue;
std::wstring expected;
for (wchar_t c : run.keys)
expected += c == L' ' ? L' ' : layouts[i].at(physical.code(c));
for (int removed = 0;
removed < 3 && !expected.empty() && openingDelimiter(expected.front(), settings); ++removed)
{
expected.erase(expected.begin());
if (lower(expected) == lower(text))
return i;
}
}
return -1;
}
bool autoCorrectionAllowed(HWND target, const Settings &settings)
{
if (!target || GetForegroundWindow() != target)
return false;
auto process = lower(processName(target));
for (auto &excluded : settings.autoExcluded)
{
auto name = lower(excluded);
if (name.ends_with(L".exe"))
name.resize(name.size() - 4);
if (name == process)
return false;
}
// Fail closed: do not copy from password fields or inaccessible controls.
IUIAutomation2 *automation{};
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
(void **)&automation)))
return false;
automation->put_ConnectionTimeout(200);
automation->put_TransactionTimeout(200);
IUIAutomationElement *focused{};
HRESULT hr = automation->GetFocusedElement(&focused);
automation->Release();
if (FAILED(hr) || !focused)
return false;
BOOL password = TRUE, enabled = FALSE, hasFocus = FALSE;
CONTROLTYPEID type{};
bool allowed = SUCCEEDED(focused->get_CurrentIsPassword(&password)) && !password &&
SUCCEEDED(focused->get_CurrentIsEnabled(&enabled)) && enabled &&
SUCCEEDED(focused->get_CurrentHasKeyboardFocus(&hasFocus)) && hasFocus &&
SUCCEEDED(focused->get_CurrentControlType(&type)) &&
(type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId);
focused->Release();
return allowed && GetForegroundWindow() == target;
}
} // namespace ks
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "platform.h"
#include <map>
#include <set>
#include <string_view>
namespace ks
{
struct AutoSuggestion
{
int source{-1}, target{-1};
std::wstring text;
};
std::wstring layoutLanguage(const Layout &);
bool safeAutoWord(const std::wstring &, int minimumLength = 3);
bool autoClosingSelection(const std::wstring &, const Settings & = {});
int matchAutoInput(const Run &, const std::wstring &, const std::vector<Layout> &, const Layout &,
const Settings & = {});
bool autoCorrectionAllowed(HWND, const Settings &);
class AutoCorrector
{
struct Dictionary
{
std::string bytes;
std::vector<uint32_t> offsets;
std::string_view word(size_t) const;
explicit Dictionary(const std::filesystem::path &);
int rank(const std::wstring &) const;
};
std::map<std::wstring, Dictionary> dictionaries;
std::vector<std::wstring> languages;
std::set<std::wstring> rejected;
public:
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
const Settings &) const;
void reject(std::wstring word, const Settings & = {});
size_t memoryBytes() const;
};
} // namespace ks
+10
View File
@@ -46,6 +46,16 @@ struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
bool autoCorrect = false;
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
std::vector<std::wstring> autoNever;
std::vector<std::wstring> autoExcluded{
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
+47 -1
View File
@@ -107,9 +107,55 @@ wchar_t physicalKey(DWORD scan, bool shift)
return (shift ? physicalLayout().shifted : physicalLayout().normal)[i];
return 0;
}
static HWND inputWindow(HWND w)
{
if (!w || !IsWindow(w))
return nullptr;
GUITHREADINFO info{sizeof(info)};
DWORD thread = w == GetForegroundWindow() ? 0 : GetWindowThreadProcessId(w, nullptr);
if (GetGUIThreadInfo(thread, &info) && info.hwndFocus &&
(info.hwndFocus == w || IsChild(w, info.hwndFocus) || GetAncestor(info.hwndFocus, GA_ROOT) == w))
return info.hwndFocus;
return w;
}
HKL windowLayout(HWND w)
{
return GetKeyboardLayout(GetWindowThreadProcessId(w, nullptr));
HWND input = inputWindow(w);
return input ? GetKeyboardLayout(GetWindowThreadProcessId(input, nullptr)) : nullptr;
}
bool switchWindowLayout(HWND target, HKL layout, const std::function<bool()> &valid)
{
HWND input = inputWindow(target);
auto unchanged = [&]
{ return input && valid() && GetForegroundWindow() == target && inputWindow(target) == input; };
if (!layout || !unchanged())
return false;
if (windowLayout(target) == layout)
return true;
// Windows directs layout requests to the focused control, which can belong
// to a different UI thread/process than a Teams/WebView top-level window.
if (!PostMessageW(input, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)layout))
return false;
for (int i = 0; i < 30; ++i)
{
if (!unchanged())
return false;
if (windowLayout(target) == layout)
return true;
// Some hosts handle this at their top level instead of the focused child.
if (i == 14 && input != target)
PostMessageW(target, WM_INPUTLANGCHANGEREQUEST, 0, (LPARAM)layout);
Sleep(10);
}
return unchanged() && windowLayout(target) == layout;
}
void showRequestedWindow(HWND window)
{
ShowWindow(window, SW_SHOW);
// STARTF_USESHOWWINDOW can override the process's first ShowWindow with
// SW_HIDE. An explicit user request must still make the window visible.
if (!IsWindowVisible(window))
ShowWindow(window, SW_SHOW);
}
bool activateWindow(HWND window)
{
+2
View File
@@ -24,7 +24,9 @@ std::vector<Layout> readLayouts();
const Layout &physicalLayout();
wchar_t physicalKey(DWORD scan, bool shift);
HKL windowLayout(HWND);
bool switchWindowLayout(HWND, HKL, const std::function<bool()> &valid);
bool activateWindow(HWND);
void showRequestedWindow(HWND);
bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
+50
View File
@@ -62,6 +62,29 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Keyboard shortcuts must be unique");
used.push_back(*key);
}
if (s.autoMinLength < 1 || s.autoMinLength > 32)
throw std::runtime_error("Minimum word length must be between 1 and 32 letters");
if (s.autoMaxRank < 0)
throw std::runtime_error("Frequency rank limit must be zero or a positive whole number");
for (const auto &characters : {s.autoPrefixChars, s.autoSuffixChars})
{
if (characters.size() > 32)
throw std::runtime_error("Use at most 32 punctuation characters in each delimiter field");
for (wchar_t c : characters)
if (letter(c) || iswalnum(c) || iswspace(c) || c < 32 || (c >= 0xd800 && c <= 0xdfff))
throw std::runtime_error(
"Word delimiters must be punctuation, without letters, digits or spaces");
}
if (s.autoCorrect && s.autoLanguages.size() < 2)
throw std::runtime_error("Select at least two languages for automatic correction");
if (std::set<std::wstring>(s.autoLanguages.begin(), s.autoLanguages.end()).size() !=
s.autoLanguages.size())
throw std::runtime_error("Dictionary languages must be unique");
for (auto &language : s.autoLanguages)
if (language != L"en" && language != L"uk" && language != L"ru")
throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions");
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
s.maxImageMiB > 64)
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
@@ -100,6 +123,20 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
s.autoCorrect = j.value("autoCorrectLayout", false);
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
s.autoMinLength = j.value("autoCorrectMinLength", 3);
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}})
if (j.contains(pair.first))
{
pair.second->clear();
for (auto &item : j.at(pair.first))
pair.second->push_back(wide(item.get<std::string>()));
}
s.maxItems = std::clamp(j.value("maxItems", 500), 10, 10000);
s.maxMiB = std::clamp(j.value("maxTotalMegabytes", 200), 1, 2048);
s.maxImageMiB = std::clamp(j.value("maxImageMegabytes", 20), 1, 64);
@@ -160,6 +197,19 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
j["layouts"] = Json::array();
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
j["autoCorrectLayout"] = s.autoCorrect;
j["autoCorrectMaxRank"] = s.autoMaxRank;
j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}})
{
j[pair.first] = Json::array();
for (auto &item : *pair.second)
j[pair.first].push_back(utf8(item));
}
j["layoutHotkeys"] = Json::array();
for (size_t i = 0; i < std::min(s.directIds.size(), s.direct.size()); ++i)
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
+170 -5
View File
@@ -8,6 +8,7 @@
#include <map>
#include <richedit.h>
#include <set>
#include <shellapi.h>
#include <sstream>
namespace ks
@@ -648,6 +649,7 @@ void fillSettings(HWND w, SettingsView *v)
check(w, 204, s.autostart);
check(w, 206, s.elevatedAutostart);
check(w, 205, s.awake);
check(w, 207, s.autoCorrect);
value(w, 210, s.toggle);
value(w, 211, s.casing);
value(w, 212, s.translit);
@@ -695,6 +697,7 @@ Settings collectSettings(HWND w, SettingsView *v)
s.autostart = checked(w, 204);
s.elevatedAutostart = checked(w, 206);
s.awake = checked(w, 205);
s.autoCorrect = checked(w, 207);
s.toggle = value(w, 210);
s.casing = value(w, 211);
s.translit = value(w, 212);
@@ -723,6 +726,158 @@ Settings collectSettings(HWND w, SettingsView *v)
validateSettings(s);
return s;
}
struct AutoOptionsView
{
HWND parent;
SettingsView *settings;
};
std::wstring commaList(const std::vector<std::wstring> &items)
{
std::wstring text;
for (auto &item : items)
{
if (!text.empty())
text += L", ";
text += item;
}
return text;
}
std::vector<std::wstring> parseCommaList(const std::wstring &text)
{
std::vector<std::wstring> items;
std::wistringstream input(text);
std::wstring item;
while (std::getline(input, item, L','))
{
auto first = item.find_first_not_of(L" \t\r\n"), last = item.find_last_not_of(L" \t\r\n");
if (first != item.npos)
items.push_back(item.substr(first, last - first + 1));
}
return items;
}
LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *view = (AutoOptionsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
if (!view)
return DefWindowProcW(w, msg, wp, lp);
if (msg == WM_COMMAND && LOWORD(wp) == 407)
{
auto folder = std::filesystem::path(executable()).parent_path() / L"dictionaries";
if ((INT_PTR)ShellExecuteW(w, L"open", folder.c_str(), nullptr, nullptr, SW_SHOWNORMAL) <= 32)
MessageBoxW(w, L"Could not open the dictionary folder.", L"KSwitcher", MB_OK | MB_ICONERROR);
return 0;
}
if (msg == WM_COMMAND && LOWORD(wp) == IDOK)
{
try
{
auto draft = view->settings->draft;
auto lengthText = value(w, 410);
if (lengthText.empty() || lengthText.size() > 2 ||
lengthText.find_first_not_of(L"0123456789") != lengthText.npos)
throw std::runtime_error("Minimum word length must be between 1 and 32 letters");
draft.autoMinLength = std::stoi(lengthText);
auto rankText = value(w, 406);
if (rankText.empty() || rankText.find_first_not_of(L"0123456789") != rankText.npos)
throw std::runtime_error("Enter a non-negative whole number for the frequency rank limit");
try
{
draft.autoMaxRank = std::stoi(rankText);
}
catch (...)
{
throw std::runtime_error("Frequency rank limit is too large");
}
draft.autoLanguages.clear();
if (checked(w, 401))
draft.autoLanguages.push_back(L"en");
if (checked(w, 402))
draft.autoLanguages.push_back(L"uk");
if (checked(w, 403))
draft.autoLanguages.push_back(L"ru");
draft.autoNever = parseCommaList(value(w, 405));
draft.autoExcluded = parseCommaList(value(w, 404));
draft.autoPrefixChars = value(w, 408);
draft.autoSuffixChars = value(w, 409);
if (draft.autoLanguages.size() < 2)
throw std::runtime_error("Select at least two languages");
validateSettings(draft);
view->settings->draft.autoLanguages = draft.autoLanguages;
view->settings->draft.autoNever = draft.autoNever;
view->settings->draft.autoExcluded = draft.autoExcluded;
view->settings->draft.autoMaxRank = draft.autoMaxRank;
view->settings->draft.autoMinLength = draft.autoMinLength;
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
DestroyWindow(w);
}
catch (const std::exception &e)
{
error(w, e);
}
return 0;
}
if ((msg == WM_COMMAND && LOWORD(wp) == IDCANCEL) || msg == WM_CLOSE)
{
DestroyWindow(w);
return 0;
}
if (msg == WM_DPICHANGED)
{
resizeForDpi(w, wp, lp);
return 0;
}
if (msg == WM_NCDESTROY)
{
EnableWindow(view->parent, TRUE);
SetForegroundWindow(view->parent);
disposeFont(w);
delete view;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
return DefWindowProcW(w, msg, wp, lp);
}
void showAutoOptions(HWND parent, SettingsView *v)
{
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
560, 680);
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
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);
checkbox(w, L"English", 401, 18, 48, 150);
checkbox(w, L"Ukrainian", 402, 188, 48, 150);
checkbox(w, L"Russian", 403, 358, 48, 150);
for (auto &lang : v->draft.autoLanguages)
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
edit(w, 404, 18, 122, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 404, commaList(v->draft.autoExcluded));
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525);
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 405, commaList(v->draft.autoNever));
label(w, L"Corrects after Space and a short pause.", 18, 344, 525);
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 525);
label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
value(w, 406, std::to_wstring(v->draft.autoMaxRank));
label(w, L"Minimum word length (1–32 letters)", 18, 434, 390);
edit(w, 410, 420, 430, 122, 26, ES_NUMBER);
value(w, 410, std::to_wstring(v->draft.autoMinLength));
label(w, L"Characters before a word", 18, 474, 250);
label(w, L"Characters after a word", 290, 474, 250);
edit(w, 408, 18, 500, 252);
edit(w, 409, 290, 500, 252);
value(w, 408, v->draft.autoPrefixChars);
value(w, 409, v->draft.autoSuffixChars);
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
button(w, L"Open dictionary folder", 407, 18, 562, 220);
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"Cancel", IDCANCEL, 440, 632, 100);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetForegroundWindow(w);
}
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_DPICHANGED)
@@ -802,6 +957,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
saveSettings(path, s);
return 0;
}
if (id == 255)
{
showAutoOptions(w, v);
return 0;
}
if (id == 252)
{
showSnippets(*v->app);
@@ -1040,7 +1200,10 @@ void showSettings(App &app)
if (app.settingsWindow)
{
ShowWindow(app.settingsWindow, SW_RESTORE);
SetForegroundWindow(app.settingsWindow);
HWND target = app.settingsWindow;
if (!IsWindowEnabled(target))
target = GetLastActivePopup(target);
activateWindow(target);
return;
}
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
@@ -1060,6 +1223,7 @@ void showSettings(App &app)
label(w, names[i], 420, 48 + i * 30, 115);
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
}
checkbox(w, L"Automatically correct layout (experimental)", 207, 420, 195, 350);
label(w, L"Layout cycle — top to bottom", 18, 210, 400);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
18, 232, 470, 190);
@@ -1080,14 +1244,15 @@ void showSettings(App &app)
button(w, L"Clear history…", 254, 580, 474, 180);
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
edit(w, 243, 18, 544, 742);
button(w, L"Auto-correction options…", 255, 18, 575, 240);
button(w, L"Import settings…", 250, 18, 606, 145);
button(w, L"Export settings…", 251, 173, 606, 145);
button(w, L"Snippets…", 252, 328, 606, 110);
button(w, L"Save", IDOK, 548, 606, 100);
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
ShowWindow(w, SW_SHOW);
SetForegroundWindow(w);
showRequestedWindow(w);
activateWindow(w);
}
void showSnippets(App &app)
{
@@ -1112,7 +1277,7 @@ void showSnippets(App &app)
button(w, L"Save", IDOK, 510, 478, 100);
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
ShowWindow(w, SW_SHOW);
showRequestedWindow(w);
SetForegroundWindow(w);
}
void showShortcuts(App &app)
@@ -1145,7 +1310,7 @@ void showEntry(App &app, const std::wstring &id)
WS_THICKFRAME | WS_MAXIMIZEBOX);
auto field = edit(w, 1, 10, 10, 700, 480, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
SetWindowTextW(field, text.c_str());
ShowWindow(w, SW_SHOW);
showRequestedWindow(w);
}
catch (const std::exception &e)
{