Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b51d341 | |||
| 0184b837f4 | |||
| ca553a5e7d | |||
| 55993c0f90 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.27 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.30 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()
|
||||||
|
|||||||
@@ -271,7 +271,11 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||||
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||||
history and cloud synchronization. Paste is not blindly retried.
|
history and cloud synchronization. Paste is not blindly retried: with manual paste readback
|
||||||
|
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
|
||||||
|
original and fresh native queries confirm that exactly the original range is still selected.
|
||||||
|
Other controls and unknown/changed selections are not retried. A verified failed paste clears
|
||||||
|
repeat-conversion state and does not switch the Windows layout.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||||
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||||
@@ -321,6 +325,10 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
|||||||
|
|
||||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||||
|
|
||||||
|
**Clear history… / Alt+H** in the history window clears all saved entries after confirmation,
|
||||||
|
including pinned entries and entries hidden by search. No is the default choice. The current
|
||||||
|
Windows clipboard and future recording are unchanged. Settings also retains its Clear history action.
|
||||||
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||||
in the context menu opens the original image in the built-in screenshot editor.
|
in the context menu opens the original image in the built-in screenshot editor.
|
||||||
Annotate it there, then copy the result. Opening the editor leaves the
|
Annotate it there, then copy the result. Opening the editor leaves the
|
||||||
@@ -336,7 +344,14 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
|||||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||||
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
|
Diagnostic stages stay in a memory buffer of at most 128 events for 30 seconds.
|
||||||
|
Selection diagnostics include the expected text, each copied candidate before and after suffix
|
||||||
|
recovery, paced Shift+Left progress or batch delivery counts, and clipboard sequence changes,
|
||||||
|
retry counts and elapsed times. Focus, caret geometry and keyboard layout are recorded when
|
||||||
|
Windows exposes them; selection offsets are available for native EDIT controls and explicitly
|
||||||
|
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
|
||||||
|
or layout-refresh events during an operation. Failed selection verification includes the expected
|
||||||
|
selection and actual copied text in the operation details, without implying that a paste occurred.
|
||||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||||
context. Successful operations stay in that bounded memory buffer and are included only when
|
context. Successful operations stay in that bounded memory buffer and are included only when
|
||||||
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
||||||
@@ -404,6 +419,10 @@ reverse-proxy URL prefixes, MFA login flows and other providers are not implemen
|
|||||||
|
|
||||||
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
|
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
|
||||||
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
|
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
|
||||||
|
When synchronization is disabled, connection fields and sync actions are inactive. Check **Enable
|
||||||
|
synchronization** to make them available. To disable an existing connection, uncheck it and click
|
||||||
|
**Save**; this preserves the saved connection and password without starting a sync request.
|
||||||
|
**Disconnect / forget password** remains available while synchronization is disabled.
|
||||||
A blank password keeps the existing Windows credential for that server, login mount and username.
|
A blank password keeps the existing Windows credential for that server, login mount and username.
|
||||||
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
||||||
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
||||||
@@ -414,11 +433,15 @@ saved credential. A request already sent to the server may still complete; its d
|
|||||||
will not be applied after the connection changes. Old credentials for other connection identities
|
will not be applied after the connection changes. Old credentials for other connection identities
|
||||||
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
|
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
|
||||||
|
|
||||||
First connection is deliberately explicit:
|
On the first connection, **Save / Sign in / Sync** merges the existing profiles without
|
||||||
|
requiring a whole-profile replacement. Independent entries (such as snippets, exclusions and
|
||||||
|
replacement pairs) are combined. Different values for the same setting remain on their respective
|
||||||
|
sides and appear in **Resolve individual conflicts...**; no value is guessed without a common history.
|
||||||
|
Nonconflicting changes synchronize immediately, and unresolved conflicts survive subsequent syncs.
|
||||||
|
|
||||||
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
||||||
- **Download remote** replaces this PC's shared settings with the server profile.
|
- **Download remote** replaces this PC's shared settings with the server profile.
|
||||||
- If both profiles are identical, ordinary Sync can establish the baseline without a replacement.
|
- If the server has no profile yet, choose **Upload local** to create it.
|
||||||
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
|
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
|
||||||
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
||||||
|
|
||||||
@@ -453,8 +476,14 @@ Deleted server versions are not silently recreated.
|
|||||||
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
||||||
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
||||||
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
||||||
history, screenshots and dictionary files are never uploaded. Missing keyboard layouts block
|
history, screenshots and dictionary files are never uploaded. Full synchronization identifies
|
||||||
applying a remote profile until those Windows layouts are installed, preventing lost bindings.
|
missing keyboard layouts by language (when Windows can identify it) and exact ID.
|
||||||
|
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
|
||||||
|
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
|
||||||
|
local layout cycle and its direct shortcuts unchanged while synchronizing the other shared settings.
|
||||||
|
The server's layout cycle and direct shortcuts are preserved on automatic sync and explicit Upload;
|
||||||
|
Download also keeps the local layout configuration. Later syncs cannot erase missing server layouts
|
||||||
|
just because this PC does not have them installed. Uncheck the option to resume full layout sync.
|
||||||
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
||||||
|
|
||||||
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||||
@@ -506,13 +535,40 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
|
|||||||
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
||||||
verified local build uses GCC/w64devkit.
|
verified local build uses GCC/w64devkit.
|
||||||
|
|
||||||
## Automatic layout correction (experimental)
|
## Automatic correction (experimental)
|
||||||
|
|
||||||
Enable **Automatically correct layout (experimental)** in Settings, then use **Auto-correction
|
Enable **Automatic correction (experimental)** in Settings, then use **Auto-correction
|
||||||
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
||||||
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
||||||
Target layouts must also be enabled in the layout cycle.
|
Target layouts must also be enabled in the layout cycle.
|
||||||
|
|
||||||
|
### Custom replacements and spelling typos
|
||||||
|
|
||||||
|
Two optional modes in **Auto-correction options…** extend layout correction. Both are off by
|
||||||
|
default and require the main **Automatic correction (experimental)** checkbox to be enabled.
|
||||||
|
|
||||||
|
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||||
|
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||||
|
are rejected. Each side must contain 1–32 letters of the same supported language, without
|
||||||
|
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
|
||||||
|
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
||||||
|
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
||||||
|
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||||
|
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||||
|
and target must have at least four letters. Multiple candidates leave the input unchanged,
|
||||||
|
even if a frequency limit would exclude one of them. Personal dictionary words participate.
|
||||||
|
This is word-level spelling correction, not grammar or sentence-context correction.
|
||||||
|
|
||||||
|
Both modes run after Space, preserve surrounding punctuation and an initial capital, and keep
|
||||||
|
the current layout. The selected dictionary languages, enabled layout cycle, minimum input
|
||||||
|
length, excluded applications, never-correct list, protected fields and Undo behavior apply.
|
||||||
|
ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped. A replacement
|
||||||
|
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
||||||
|
are included in settings export/import and synchronization. Undo restores the original word
|
||||||
|
and suppresses its correction until restart.
|
||||||
|
|
||||||
|
### Layout correction and shared options
|
||||||
|
|
||||||
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
||||||
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
||||||
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
||||||
|
|||||||
Binary file not shown.
+9
-9
@@ -1,23 +1,23 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.27 — 2026-11-02
|
## v0.6.30 — 2026-11-02
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Tray and clipboard context menus support underlined access keys. Press the letter while a menu is open (S for Settings, X for Exit).
|
- A manual paste confirmed unsuccessful by readback no longer records the expected replacement as a previous result or switches the Windows layout. This prevents repeated commands from searching for text that was never inserted.
|
||||||
- Alt+letter activates buttons and checkboxes in Settings, Snippets, Logs, synchronization/conflict, OCR and dictionary dialogs. Selected field labels also move focus to their input.
|
- A previous-result verification mismatch clears stale repeat-conversion state.
|
||||||
- Clipboard history supports Alt+letter for its actions and search while retaining Enter, Shift+Enter and Ctrl+digit shortcuts.
|
- A writable native Windows EDIT field may receive one guarded paste retry: readback must equal the original text, and fresh native checks must confirm the exact original selection, unchanged focus and input context. Other controls, protected/read-only fields and unknown/changed selections are not retried.
|
||||||
- Press Alt to reveal Windows' hidden underlines. Shortcut capture still records Alt combinations; press Tab to leave the capture field before using form access keys. Captions and access keys currently use English letters.
|
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
91 sync/protocol/conflict checks, 423 native regressions, 513 input/UI checks (including Windows Alt+K dispatch and checkbox toggling), shared wheel handling, screenshot editor and PNG save regressions, notification registration, 48 updater checks and updater progress checks.
|
The final isolated conversion integration run passed: permanently blocked paste, unexpected editor content, clearing fictional previous results, ignored-first-paste recovery, clipboard restoration, repeat conversion and partial-selection recovery. Typo integration also passed, including Undo and uninterrupted typing. 541 input/UI checks (240 DPI), 105 sync/protocol checks, 483 native regressions and 48 updater checks passed. All release executable versions and manifest hashes were verified. Earlier interactive attempts were interrupted by concurrent input or a transient clipboard-restoration assertion; the final isolated rerun passed.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
OpenBao synchronization is experimental and requires a reachable HTTPS server with a trusted certificate, KV v2 and a provisioned userpass account/policy. It does not provide account registration, MFA login, non-root namespaces or reverse-proxy URL prefixes. Ordered layout/language lists are resolved as whole settings. Deletion records are retained without expiry. Missing keyboard layouts block applying a profile. Download/Upload replace a whole profile and are intended for initial setup or deliberate recovery; use Resolve individual conflicts for normal conflicts. Existing browser-selection and clipboard/accessibility limitations remain unchanged.
|
The cause of every ignored paste is not established. This release does not claim to solve all asynchronous selection failures in ChatGPT/browser editors. Keep Write log enabled and send complete operation details if the issue recurs. Native retry is bounded to one attempt; it is not a general retry policy for other applications. All existing dictionary-correction and synchronization limitations remain.
|
||||||
|
|
||||||
**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, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, passwords, server address, clipboard history, Tesseract installation or language models.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package includes all three executables, a release manifest, offline dictionaries, documentation and license notices. No personal settings, passwords, server address or clipboard history are included.
|
||||||
|
|
||||||
Build and package:
|
Build and package:
|
||||||
|
|
||||||
@@ -26,4 +26,4 @@ Build and package:
|
|||||||
.\package.ps1 -PublishRelease
|
.\package.ps1 -PublishRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
@@ -1 +1 @@
|
|||||||
e6597d11fc388979e972c8572e2471a00dec6c3782414a22d396072508fe7329 KSwitcher-win-x64.zip
|
7e557851ab68a37a8211ea39de41e79314f460ef8ac6471be723d0cfe03cfba9 KSwitcher-win-x64.zip
|
||||||
|
|||||||
+3
-3
@@ -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,27,0
|
FILEVERSION 0,6,30,0
|
||||||
PRODUCTVERSION 0,6,27,0
|
PRODUCTVERSION 0,6,30,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.27"
|
VALUE "FileVersion", "0.6.30"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+125
-7
@@ -73,6 +73,8 @@ class Transaction
|
|||||||
Clip snapshot;
|
Clip snapshot;
|
||||||
DWORD expected{};
|
DWORD expected{};
|
||||||
bool captured{};
|
bool captured{};
|
||||||
|
mutable bool invalidLogged{};
|
||||||
|
unsigned copyNumber{};
|
||||||
std::wstring adopted;
|
std::wstring adopted;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -108,8 +110,18 @@ class Transaction
|
|||||||
}
|
}
|
||||||
bool valid() const
|
bool valid() const
|
||||||
{
|
{
|
||||||
return target && IsWindow(target) && GetForegroundWindow() == target &&
|
auto foreground = GetForegroundWindow();
|
||||||
app.inputVersion.load() == version;
|
auto currentVersion = app.inputVersion.load();
|
||||||
|
bool ok = target && IsWindow(target) && foreground == target && currentVersion == version;
|
||||||
|
if (!ok && !invalidLogged)
|
||||||
|
{
|
||||||
|
invalidLogged = true;
|
||||||
|
diagnosticEvent(L"Context invalid: target=" + std::to_wstring((uintptr_t)target) +
|
||||||
|
L" alive=" + std::to_wstring(IsWindow(target)) + L" foreground=" +
|
||||||
|
std::to_wstring((uintptr_t)foreground) + L" expectedVersion=" + std::to_wstring(version) +
|
||||||
|
L" currentVersion=" + std::to_wstring(currentVersion));
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
void check()
|
void check()
|
||||||
{
|
{
|
||||||
@@ -144,10 +156,14 @@ class Transaction
|
|||||||
}
|
}
|
||||||
std::wstring copy()
|
std::wstring copy()
|
||||||
{
|
{
|
||||||
|
const auto started = GetTickCount64();
|
||||||
|
const auto number = ++copyNumber;
|
||||||
if (!valid())
|
if (!valid())
|
||||||
return {};
|
return {};
|
||||||
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
||||||
DWORD before = expected;
|
DWORD before = expected;
|
||||||
|
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
|
||||||
|
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target));
|
||||||
if (!valid() || !chord(L'C', MOD_CONTROL))
|
if (!valid() || !chord(L'C', MOD_CONTROL))
|
||||||
{
|
{
|
||||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||||
@@ -166,6 +182,10 @@ class Transaction
|
|||||||
DWORD now = GetClipboardSequenceNumber();
|
DWORD now = GetClipboardSequenceNumber();
|
||||||
if (now != changed)
|
if (now != changed)
|
||||||
{
|
{
|
||||||
|
diagnosticEvent(L"Copy sequence number=" + std::to_wstring(number) + L" from=" +
|
||||||
|
std::to_wstring(changed) + L" to=" + std::to_wstring(now) + L" elapsedMs=" +
|
||||||
|
std::to_wstring(GetTickCount64() - started) + L" owner=" +
|
||||||
|
std::to_wstring((uintptr_t)GetClipboardOwner()));
|
||||||
changedAgain |= changed != before;
|
changedAgain |= changed != before;
|
||||||
changed = now;
|
changed = now;
|
||||||
stable = GetTickCount64();
|
stable = GetTickCount64();
|
||||||
@@ -183,7 +203,8 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
if (!chord(L'C', MOD_CONTROL))
|
if (!chord(L'C', MOD_CONTROL))
|
||||||
return {};
|
return {};
|
||||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
|
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
|
||||||
|
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||||
++attempts;
|
++attempts;
|
||||||
retryAt = GetTickCount64() + 250;
|
retryAt = GetTickCount64() + 250;
|
||||||
}
|
}
|
||||||
@@ -191,6 +212,10 @@ class Transaction
|
|||||||
}
|
}
|
||||||
if (changed == before || !valid())
|
if (changed == before || !valid())
|
||||||
{
|
{
|
||||||
|
diagnosticEvent(L"Copy end number=" + std::to_wstring(number) + L" attempts=" +
|
||||||
|
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
|
||||||
|
L" sequence=" + std::to_wstring(GetClipboardSequenceNumber()) + L" clipboardOpenBy=" +
|
||||||
|
std::to_wstring((uintptr_t)GetOpenClipboardWindow()));
|
||||||
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
||||||
L"Copy failed: context changed");
|
L"Copy failed: context changed");
|
||||||
return {};
|
return {};
|
||||||
@@ -201,6 +226,9 @@ class Transaction
|
|||||||
expected = changed;
|
expected = changed;
|
||||||
auto clip = readClipboard(app.window);
|
auto clip = readClipboard(app.window);
|
||||||
auto text = clip.text();
|
auto text = clip.text();
|
||||||
|
diagnosticEvent(L"Copy result number=" + std::to_wstring(number) + L" attempts=" +
|
||||||
|
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
|
||||||
|
L" text=" + diagnosticText(text));
|
||||||
adopted = text;
|
adopted = text;
|
||||||
check();
|
check();
|
||||||
for (auto &f : clip.formats)
|
for (auto &f : clip.formats)
|
||||||
@@ -233,6 +261,27 @@ class Transaction
|
|||||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||||
{
|
{
|
||||||
// WebView word boundaries are only candidates; exact verification is mandatory.
|
// WebView word boundaries are only candidates; exact verification is mandatory.
|
||||||
|
diagnosticEvent(L"Selection begin steps=" + std::to_wstring(count) + L" expected=" + diagnosticText(wordCandidate));
|
||||||
|
GUITHREADINFO gui{sizeof(gui)};
|
||||||
|
if (GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui))
|
||||||
|
{
|
||||||
|
wchar_t cls[128]{};
|
||||||
|
GetClassNameW(gui.hwndFocus, cls, 128);
|
||||||
|
std::wstring offsets = L"unavailable";
|
||||||
|
if (_wcsicmp(cls, L"Edit") == 0)
|
||||||
|
{
|
||||||
|
DWORD_PTR range{};
|
||||||
|
if (SendMessageTimeoutW(gui.hwndFocus, EM_GETSEL, 0, 0,
|
||||||
|
SMTO_ABORTIFHUNG | SMTO_BLOCK, 25, &range) && (DWORD)range != 0xffffffff)
|
||||||
|
offsets = std::to_wstring(LOWORD(range)) + L"," + std::to_wstring(HIWORD(range));
|
||||||
|
}
|
||||||
|
diagnosticEvent(L"Selection editor class=" + std::wstring(cls) + L" focus=" +
|
||||||
|
std::to_wstring((uintptr_t)gui.hwndFocus) + L" caretWindow=" + std::to_wstring((uintptr_t)gui.hwndCaret) +
|
||||||
|
L" caretRect=" + std::to_wstring(gui.rcCaret.left) + L"," + std::to_wstring(gui.rcCaret.top) +
|
||||||
|
L"," + std::to_wstring(gui.rcCaret.right) + L"," + std::to_wstring(gui.rcCaret.bottom) +
|
||||||
|
L" layout=" + std::to_wstring((uintptr_t)windowLayout(target)) + L" selectionOffsets=" + offsets);
|
||||||
|
}
|
||||||
|
else diagnosticEvent(L"Selection editor: focus/caret/selection unavailable");
|
||||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||||
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
||||||
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
||||||
@@ -241,6 +290,7 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
auto text = copy();
|
auto text = copy();
|
||||||
|
diagnosticEvent(L"Selection word-boundary match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||||
if (valid() && matches(text))
|
if (valid() && matches(text))
|
||||||
{
|
{
|
||||||
diagnosticEvent(L"Selection: verified word boundary");
|
diagnosticEvent(L"Selection: verified word boundary");
|
||||||
@@ -251,6 +301,7 @@ class Transaction
|
|||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
auto text = select(count) ? copy() : std::wstring{};
|
auto text = select(count) ? copy() : std::wstring{};
|
||||||
|
diagnosticEvent(L"Selection initial match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||||
// Chromium editors can apply only part of a burst of Shift+Left events.
|
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||||
// Extend only a verified suffix; never replace a partial or unrelated range.
|
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||||
if (valid() && !matches(text) && !text.empty() &&
|
if (valid() && !matches(text) && !text.empty() &&
|
||||||
@@ -263,10 +314,11 @@ class Transaction
|
|||||||
{
|
{
|
||||||
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||||
return {};
|
return {};
|
||||||
|
diagnosticEvent(L"Selection recovery Shift+Left sent=" + std::to_wstring(i + 1) + L" total=" + std::to_wstring(missing));
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
text = copy();
|
text = copy();
|
||||||
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
@@ -286,6 +338,32 @@ class Transaction
|
|||||||
Sleep(300);
|
Sleep(300);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
bool nativeSelectionIs(const std::wstring &original) const
|
||||||
|
{
|
||||||
|
if (!valid()) return false;
|
||||||
|
GUITHREADINFO gui{sizeof(gui)};
|
||||||
|
if (!GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui) || !gui.hwndFocus) return false;
|
||||||
|
wchar_t cls[32]{};
|
||||||
|
GetClassNameW(gui.hwndFocus, cls, 32);
|
||||||
|
if (_wcsicmp(cls, L"Edit") != 0 ||
|
||||||
|
(GetWindowLongPtrW(gui.hwndFocus, GWL_STYLE) & (ES_PASSWORD | ES_READONLY))) return false;
|
||||||
|
auto query = [&](UINT message, WPARAM wp, LPARAM lp, DWORD_PTR &result)
|
||||||
|
{
|
||||||
|
return SendMessageTimeoutW(gui.hwndFocus, message, wp, lp,
|
||||||
|
SMTO_ABORTIFHUNG | SMTO_BLOCK, 100, &result) != 0;
|
||||||
|
};
|
||||||
|
DWORD_PTR range{}, length{}, copied{};
|
||||||
|
if (!query(EM_GETSEL, 0, 0, range) || (DWORD)range == 0xffffffff ||
|
||||||
|
size_t(HIWORD(range) - LOWORD(range)) != original.size() ||
|
||||||
|
!query(WM_GETTEXTLENGTH, 0, 0, length) || length > 65534 || HIWORD(range) > length) return false;
|
||||||
|
std::wstring content(length + 1, L'\0');
|
||||||
|
if (!query(WM_GETTEXT, content.size(), (LPARAM)content.data(), copied) || copied != length) return false;
|
||||||
|
DWORD_PTR after{};
|
||||||
|
GUITHREADINFO focused{sizeof(focused)};
|
||||||
|
return query(EM_GETSEL, 0, 0, after) && after == range && valid() &&
|
||||||
|
GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &focused) && focused.hwndFocus == gui.hwndFocus &&
|
||||||
|
content.substr(LOWORD(range), original.size()) == original;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||||
@@ -532,6 +610,7 @@ void App::applySettings(const Settings &requested)
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
|
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
|
||||||
}
|
}
|
||||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
@@ -560,6 +639,7 @@ void App::reloadLayouts()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
|
if (busy) diagnosticEvent(L"Input context changed: layouts reloaded version=" + std::to_wstring(inputVersion.load()));
|
||||||
}
|
}
|
||||||
registerHotkeys(settings);
|
registerHotkeys(settings);
|
||||||
}
|
}
|
||||||
@@ -589,6 +669,7 @@ void App::resetInput()
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
|
if (busy) diagnosticEvent(L"Input context changed: reset version=" + std::to_wstring(inputVersion.load()));
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
@@ -606,6 +687,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
if (isHotkey(vk, mods))
|
if (isHotkey(vk, mods))
|
||||||
return;
|
return;
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
|
if (busy) diagnosticEvent(L"Input context changed: keyboard vk=" + std::to_wstring(vk) +
|
||||||
|
L" modifiers=" + std::to_wstring(mods) + L" injected=" + std::to_wstring((k.flags & LLKHF_INJECTED) != 0) +
|
||||||
|
L" version=" + std::to_wstring(inputVersion.load()));
|
||||||
last.reset();
|
last.reset();
|
||||||
HWND fg = GetForegroundWindow();
|
HWND fg = GetForegroundWindow();
|
||||||
HKL layout = windowLayout(fg);
|
HKL layout = windowLayout(fg);
|
||||||
@@ -839,8 +923,11 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
|||||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||||
// range just like local clicks, including while a transaction is pending.
|
// range just like local clicks, including while a transaction is pending.
|
||||||
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
||||||
|
{
|
||||||
|
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
|
||||||
current->resetInput();
|
current->resetInput();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return CallNextHookEx(nullptr, code, wp, lp);
|
return CallNextHookEx(nullptr, code, wp, lp);
|
||||||
}
|
}
|
||||||
void App::dispatch(int command)
|
void App::dispatch(int command)
|
||||||
@@ -1010,9 +1097,10 @@ void App::transform(int command, int snippet)
|
|||||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
!(command == 0 && reason == L"paste-sent-unverified")},
|
||||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
||||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
||||||
{"expected", bounded(result)}, {"actual", bounded(actual)},
|
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
|
||||||
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
|
{"selectionExpected", bounded(expected)},
|
||||||
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
|
{"originalUnits", text.size()}, {"expectedUnits", (verification == L"selection-mismatch" ? expected : result).size()}, {"actualUnits", actual.size()},
|
||||||
|
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096 || expected.size() > 4096}};
|
||||||
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
|
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
|
||||||
}
|
}
|
||||||
catch (...) {} // Logging must never abort a completed input transaction.
|
catch (...) {} // Logging must never abort a completed input transaction.
|
||||||
@@ -1135,6 +1223,8 @@ void App::transform(int command, int snippet)
|
|||||||
if (newlines(text) != newlines(previous->result))
|
if (newlines(text) != newlines(previous->result))
|
||||||
{
|
{
|
||||||
reason = L"undo-verification-mismatch";
|
reason = L"undo-verification-mismatch";
|
||||||
|
actual = text;
|
||||||
|
verification = L"selection-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -1177,6 +1267,8 @@ void App::transform(int command, int snippet)
|
|||||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||||
if (hint < 0)
|
if (hint < 0)
|
||||||
{
|
{
|
||||||
|
actual = text;
|
||||||
|
verification = L"selection-mismatch";
|
||||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
@@ -1194,7 +1286,14 @@ void App::transform(int command, int snippet)
|
|||||||
{ return newlines(candidate) == newlines(previous->result); });
|
{ return newlines(candidate) == newlines(previous->result); });
|
||||||
if (newlines(text) != newlines(previous->result))
|
if (newlines(text) != newlines(previous->result))
|
||||||
{
|
{
|
||||||
|
actual = text;
|
||||||
|
verification = L"selection-mismatch";
|
||||||
reason = L"previous-result-verification-mismatch";
|
reason = L"previous-result-verification-mismatch";
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
}
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -1264,11 +1363,30 @@ void App::transform(int command, int snippet)
|
|||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
{
|
{
|
||||||
auto observed = probe.read((int)caretSteps(result));
|
auto observed = probe.read((int)caretSteps(result));
|
||||||
|
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||||
|
// Retry once only with fresh native proof that the exact original range
|
||||||
|
// is still selected. Never retry an unknown or changed editor state.
|
||||||
|
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
||||||
|
if (tx.paste(textClip(result)) && tx.valid())
|
||||||
|
observed = probe.read((int)caretSteps(result));
|
||||||
|
else
|
||||||
|
observed.reset();
|
||||||
|
}
|
||||||
verification = probe.status;
|
verification = probe.status;
|
||||||
if (observed && tx.valid())
|
if (observed && tx.valid())
|
||||||
{
|
{
|
||||||
actual = *observed;
|
actual = *observed;
|
||||||
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
||||||
|
if (newlines(actual) != newlines(result))
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
diagnosticEvent(L"Paste rejected by readback: previous result cleared; layout not switched");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else reason = L"Paste not verified";
|
else reason = L"Paste not verified";
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -35,7 +35,10 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
|
|||||||
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
|
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
|
||||||
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
||||||
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
|
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
|
||||||
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts); }, choices);
|
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts, c.keepLocalLayouts); }, choices,
|
||||||
|
c.keepLocalLayouts);
|
||||||
|
if (result.success && c.keepLocalLayouts)
|
||||||
|
result.status += L" Partial sync: this PC's layouts and their shortcuts were kept; server layouts were preserved.";
|
||||||
}
|
}
|
||||||
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
||||||
catch (const std::exception &e) { result.status = wide(e.what()); }
|
catch (const std::exception &e) { result.status = wide(e.what()); }
|
||||||
@@ -65,7 +68,7 @@ void App::pollSync()
|
|||||||
nextSync = GetTickCount64() + 2000;
|
nextSync = GetTickCount64() + 2000;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts);
|
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
||||||
if (encodeSettings(next) != syncSnapshot)
|
if (encodeSettings(next) != syncSnapshot)
|
||||||
{
|
{
|
||||||
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
||||||
|
|||||||
@@ -164,6 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||||
if (!dictionaries.contains(sourceLanguage))
|
if (!dictionaries.contains(sourceLanguage))
|
||||||
return {};
|
return {};
|
||||||
|
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||||
|
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||||||
|
auto small = lower(word);
|
||||||
|
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
|
||||||
|
small.find_first_not_of(alphabet) == small.npos &&
|
||||||
|
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||||
|
auto sameLayout = [&](std::wstring text)
|
||||||
|
{
|
||||||
|
if (word != small) text[0] = upper(text.substr(0, 1))[0];
|
||||||
|
return AutoSuggestion{source, source, prefix + text + suffix};
|
||||||
|
};
|
||||||
|
if (settings.autoReplace && sameLanguage)
|
||||||
|
{
|
||||||
|
auto replacement = settings.autoReplacements.find(small);
|
||||||
|
if (replacement != settings.autoReplacements.end() &&
|
||||||
|
replacement->second.find_first_not_of(alphabet) == small.npos)
|
||||||
|
return sameLayout(replacement->second);
|
||||||
|
}
|
||||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
{
|
{
|
||||||
auto found = settings.autoWords.find(language);
|
auto found = settings.autoWords.find(language);
|
||||||
@@ -203,7 +221,54 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
(!candidate || priority(i) < priority(candidate->target)))
|
(!candidate || priority(i) < priority(candidate->target)))
|
||||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||||
}
|
}
|
||||||
|
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||||||
return candidate;
|
return candidate;
|
||||||
|
// Generate only single edits. Stop at a second distinct dictionary hit,
|
||||||
|
// including rare hits: frequency limits must never hide ambiguity.
|
||||||
|
std::wstring found;
|
||||||
|
int foundRank = 0;
|
||||||
|
std::set<std::wstring> personalTargets;
|
||||||
|
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
|
||||||
|
for (const auto &text : it->second) personalTargets.insert(lower(text));
|
||||||
|
auto consider = [&](const std::wstring &text)
|
||||||
|
{
|
||||||
|
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||||||
|
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
|
||||||
|
if (!rank) return true;
|
||||||
|
if (!found.empty()) return false;
|
||||||
|
found = text;
|
||||||
|
foundRank = rank;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < small.size(); ++i)
|
||||||
|
{
|
||||||
|
auto text = small;
|
||||||
|
text.erase(i, 1);
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
if (i + 1 < small.size())
|
||||||
|
{
|
||||||
|
text = small;
|
||||||
|
std::swap(text[i], text[i + 1]);
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
}
|
||||||
|
for (wchar_t c : alphabet)
|
||||||
|
{
|
||||||
|
text = small;
|
||||||
|
text[i] = c;
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (small.size() < 32)
|
||||||
|
for (size_t i = 0; i <= small.size(); ++i)
|
||||||
|
for (wchar_t c : alphabet)
|
||||||
|
{
|
||||||
|
auto text = small;
|
||||||
|
text.insert(i, 1, c);
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
}
|
||||||
|
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
|
||||||
|
return sameLayout(found);
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||||||
const Layout &physical, const Settings &settings)
|
const Layout &physical, const Settings &settings)
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ struct Settings
|
|||||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||||
bool elevatedAutostart = false;
|
bool elevatedAutostart = false;
|
||||||
bool autoCorrect = false;
|
bool autoCorrect = false;
|
||||||
|
bool autoReplace = false, autoTypos = false;
|
||||||
|
std::map<std::wstring, std::wstring> autoReplacements;
|
||||||
bool writeLog = true;
|
bool writeLog = true;
|
||||||
bool screenshotAutoOpenEditor = false;
|
bool screenshotAutoOpenEditor = false;
|
||||||
bool useTesseract = false;
|
bool useTesseract = false;
|
||||||
|
|||||||
+2
-1
@@ -37,7 +37,8 @@ inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &conten
|
|||||||
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
||||||
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||||
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
||||||
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
|
field("original") + L"\r\n\r\nExpected selection:\r\n" + field("selectionExpected") +
|
||||||
|
L"\r\n\r\nExpected result / verification target:\r\n" + field("expected") +
|
||||||
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
||||||
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
||||||
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
||||||
|
|||||||
+9
-2
@@ -407,12 +407,16 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
|||||||
// Shift+Left events can all use the same caret position and select only one
|
// Shift+Left events can all use the same caret position and select only one
|
||||||
// character. Give each change time to settle before sending the next one.
|
// character. Give each change time to settle before sending the next one.
|
||||||
const auto target = GetForegroundWindow();
|
const auto target = GetForegroundWindow();
|
||||||
|
diagnosticEvent(L"SelectPrevious requested=" + std::to_wstring(count) +
|
||||||
|
L" mode=" + (needsSelectionDelay(target) ? std::wstring(L"paced-25ms") : std::wstring(L"batch")));
|
||||||
if (needsSelectionDelay(target))
|
if (needsSelectionDelay(target))
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < count; ++i)
|
for (size_t i = 0; i < count; ++i)
|
||||||
{
|
{
|
||||||
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
||||||
return false;
|
return false;
|
||||||
|
if (i < 32 || i + 1 == count)
|
||||||
|
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
return valid() && GetForegroundWindow() == target;
|
return valid() && GetForegroundWindow() == target;
|
||||||
@@ -434,7 +438,10 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
|||||||
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
|
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
|
||||||
}
|
}
|
||||||
add(VK_SHIFT, KEYEVENTF_KEYUP);
|
add(VK_SHIFT, KEYEVENTF_KEYUP);
|
||||||
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
|
auto sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
|
||||||
|
diagnosticEvent(L"SelectPrevious batch offset=" + std::to_wstring(offset) + L" eventsRequested=" +
|
||||||
|
std::to_wstring(inputs.size()) + L" eventsSent=" + std::to_wstring(sent));
|
||||||
|
if (sent != inputs.size())
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return valid();
|
return valid();
|
||||||
@@ -574,7 +581,7 @@ DiagnosticBuffer diagnosticBuffer;
|
|||||||
}
|
}
|
||||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
||||||
{
|
{
|
||||||
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
|
while (!entries.empty() && (entries.size() >= 128 || now - entries.front().first > 30000))
|
||||||
entries.pop_front();
|
entries.pop_front();
|
||||||
entries.emplace_back(now, message);
|
entries.emplace_back(now, message);
|
||||||
if (!problem)
|
if (!problem)
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ void validateSettings(const Settings &s)
|
|||||||
throw std::runtime_error("Unsupported auto-correction language");
|
throw std::runtime_error("Unsupported auto-correction language");
|
||||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||||
throw std::runtime_error("Too many auto-correction exceptions");
|
throw std::runtime_error("Too many auto-correction exceptions");
|
||||||
|
if (s.autoReplacements.size() > 10000)
|
||||||
|
throw std::runtime_error("Too many automatic replacements");
|
||||||
|
for (const auto &[from, to] : s.autoReplacements)
|
||||||
|
{
|
||||||
|
bool valid = false;
|
||||||
|
for (auto alphabet : {L"abcdefghijklmnopqrstuvwxyz", L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
|
||||||
|
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||||
|
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||||
|
from == lower(from) && to == lower(to) && from != to &&
|
||||||
|
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
|
||||||
|
if (!valid)
|
||||||
|
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
|
||||||
|
}
|
||||||
for (const auto &[language, words] : s.autoWords)
|
for (const auto &[language, words] : s.autoWords)
|
||||||
{
|
{
|
||||||
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||||
@@ -172,6 +185,15 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||||
s.awake = j.value("preventScreenLock", false);
|
s.awake = j.value("preventScreenLock", false);
|
||||||
s.autoCorrect = j.value("autoCorrectLayout", false);
|
s.autoCorrect = j.value("autoCorrectLayout", false);
|
||||||
|
s.autoReplace = j.value("autoCorrectReplace", false);
|
||||||
|
s.autoTypos = j.value("autoCorrectTypos", false);
|
||||||
|
if (j.contains("autoCorrectReplacements"))
|
||||||
|
for (auto &[from, to] : j.at("autoCorrectReplacements").items())
|
||||||
|
{
|
||||||
|
auto key = lower(wide(from));
|
||||||
|
if (!s.autoReplacements.emplace(key, lower(wide(to.get<std::string>()))).second)
|
||||||
|
throw std::runtime_error("Duplicate automatic replacement");
|
||||||
|
}
|
||||||
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
||||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||||
@@ -268,6 +290,11 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
for (auto &id : s.cycle)
|
for (auto &id : s.cycle)
|
||||||
j["layouts"].push_back(utf8(id));
|
j["layouts"].push_back(utf8(id));
|
||||||
j["autoCorrectLayout"] = s.autoCorrect;
|
j["autoCorrectLayout"] = s.autoCorrect;
|
||||||
|
j["autoCorrectReplace"] = s.autoReplace;
|
||||||
|
j["autoCorrectTypos"] = s.autoTypos;
|
||||||
|
j["autoCorrectReplacements"] = Json::object();
|
||||||
|
for (const auto &[from, to] : s.autoReplacements)
|
||||||
|
j["autoCorrectReplacements"][utf8(from)] = utf8(to);
|
||||||
j["autoCorrectMaxRank"] = s.autoMaxRank;
|
j["autoCorrectMaxRank"] = s.autoMaxRank;
|
||||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
||||||
|
|||||||
+47
-10
@@ -17,7 +17,7 @@ Json configJson(const SyncConfig &c)
|
|||||||
{
|
{
|
||||||
return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)},
|
return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)},
|
||||||
{"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)},
|
{"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)},
|
||||||
{"username", utf8(c.username)}};
|
{"username", utf8(c.username)}, {"keepLocalLayouts", c.keepLocalLayouts}};
|
||||||
}
|
}
|
||||||
std::wstring credentialName(const SyncConfig &c)
|
std::wstring credentialName(const SyncConfig &c)
|
||||||
{
|
{
|
||||||
@@ -154,6 +154,7 @@ SyncConfig loadSyncConfig(const std::filesystem::path &root)
|
|||||||
if (!syncVisible(root)) return c;
|
if (!syncVisible(root)) return c;
|
||||||
auto j = parse(readFile(root / L"sync.json", 16384));
|
auto j = parse(readFile(root / L"sync.json", 16384));
|
||||||
c.enabled = j.value("enabled", false);
|
c.enabled = j.value("enabled", false);
|
||||||
|
c.keepLocalLayouts = j.value("keepLocalLayouts", false);
|
||||||
c.provider = wide(j.value("provider", "openbao"));
|
c.provider = wide(j.value("provider", "openbao"));
|
||||||
c.server = wide(j.value("server", ""));
|
c.server = wide(j.value("server", ""));
|
||||||
c.mount = wide(j.value("mount", "kswitcher"));
|
c.mount = wide(j.value("mount", "kswitcher"));
|
||||||
@@ -221,30 +222,50 @@ Bytes sharedSyncSettings(const Settings &s)
|
|||||||
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
||||||
return bytes(canonicalProfile(j));
|
return bytes(canonicalProfile(j));
|
||||||
}
|
}
|
||||||
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts)
|
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
||||||
{
|
{
|
||||||
auto j = parse(shared), original = parse(encodeSettings(local));
|
auto j = parse(shared), original = parse(encodeSettings(local));
|
||||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
|
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||||
|
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||||
|
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||||
auto allowed = parse(sharedSyncSettings(local));
|
auto allowed = parse(sharedSyncSettings(local));
|
||||||
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||||
throw std::runtime_error("Unsupported synchronized settings format.");
|
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||||
for (auto it = j.begin(); it != j.end(); ++it)
|
for (auto it = j.begin(); it != j.end(); ++it)
|
||||||
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
|
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
|
||||||
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
|
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
|
||||||
// decodeSettings intentionally ignores hotkeys for missing layouts during import.
|
|
||||||
// Sync must stop instead, otherwise the next upload would lose those bindings.
|
|
||||||
std::set<std::string> installed;
|
std::set<std::string> installed;
|
||||||
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
||||||
|
std::set<std::string> missing;
|
||||||
for (const auto &id : j.at("layouts"))
|
for (const auto &id : j.at("layouts"))
|
||||||
if (!installed.contains(id.get<std::string>())) throw std::runtime_error("Install the remote profile's keyboard layouts before syncing.");
|
if (!installed.contains(id.get<std::string>())) missing.insert(id.get<std::string>());
|
||||||
for (const auto &key : j.at("layoutHotkeys"))
|
for (const auto &key : j.at("layoutHotkeys"))
|
||||||
if (!installed.contains(key.at("layoutId").get<std::string>())) throw std::runtime_error("Install the remote profile's keyboard layouts before syncing.");
|
if (!installed.contains(key.at("layoutId").get<std::string>())) missing.insert(key.at("layoutId").get<std::string>());
|
||||||
|
if (!keepLocalLayouts && !missing.empty())
|
||||||
|
{
|
||||||
|
std::wstring message = L"Missing keyboard layouts on this PC: ";
|
||||||
|
for (const auto &id : missing)
|
||||||
|
{
|
||||||
|
auto code = id.starts_with("win:") ? id.substr(4) : id;
|
||||||
|
wchar_t language[128]{};
|
||||||
|
if (code.size() == 8 && code.find_first_not_of("0123456789abcdefABCDEF") == code.npos)
|
||||||
|
GetLocaleInfoW(MAKELCID(LOWORD(std::stoul(code, nullptr, 16)), SORT_DEFAULT), LOCALE_SENGLISHLANGUAGENAME,
|
||||||
|
language, 128);
|
||||||
|
if (message.back() != L' ') message += L"; ";
|
||||||
|
message += (language[0] ? std::wstring(language) : L"Unknown layout") + L" [" + wide(id) + L"]";
|
||||||
|
}
|
||||||
|
message += L". Install these exact layouts, or enable 'Keep this PC's layouts' below and sync other settings.";
|
||||||
|
throw std::runtime_error(utf8(message));
|
||||||
|
}
|
||||||
|
if (keepLocalLayouts)
|
||||||
|
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = original.at(key);
|
||||||
original.update(j);
|
original.update(j);
|
||||||
return decodeSettings(bytes(original), layouts);
|
return decodeSettings(bytes(original), layouts);
|
||||||
}
|
}
|
||||||
#include "sync_merge.inl"
|
#include "sync_merge.inl"
|
||||||
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
|
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
|
||||||
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices)
|
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices, bool keepLocalLayouts)
|
||||||
{
|
{
|
||||||
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
|
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
|
||||||
if (validate) validate(local);
|
if (validate) validate(local);
|
||||||
@@ -255,11 +276,25 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
|
|||||||
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
|
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
|
||||||
}
|
}
|
||||||
if (validate && action != SyncAction::Upload) validate(remote.settings);
|
if (validate && action != SyncAction::Upload) validate(remote.settings);
|
||||||
|
auto mergeLocal = local, mergeBase = base;
|
||||||
|
if (keepLocalLayouts)
|
||||||
|
{
|
||||||
|
// Never upload a locally filtered projection over another PC's layout setup.
|
||||||
|
const auto remoteJson = parse(remote.settings);
|
||||||
|
auto preserve = [&](const Bytes &profile)
|
||||||
|
{
|
||||||
|
auto j = parse(profile);
|
||||||
|
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = remoteJson.at(key);
|
||||||
|
return bytes(j);
|
||||||
|
};
|
||||||
|
mergeLocal = preserve(local);
|
||||||
|
if (!base.empty()) mergeBase = preserve(base);
|
||||||
|
}
|
||||||
SyncMerge merged;
|
SyncMerge merged;
|
||||||
if (action == SyncAction::Automatic) merged = mergeSyncProfile(base, local, remote.settings, choices);
|
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : local)));
|
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal)));
|
||||||
merged = {selected, selected, selected, {}};
|
merged = {selected, selected, selected, {}};
|
||||||
}
|
}
|
||||||
if (validate) { validate(merged.local); validate(merged.remote); }
|
if (validate) { validate(merged.local); validate(merged.remote); }
|
||||||
@@ -308,7 +343,9 @@ Bytes loadSyncBase(const std::filesystem::path &root, const SyncConfig &c)
|
|||||||
auto path = root / L"sync-base.bin";
|
auto path = root / L"sync-base.bin";
|
||||||
if (!std::filesystem::exists(path)) return {};
|
if (!std::filesystem::exists(path)) return {};
|
||||||
auto j = parse(unprotect(readFile(path, Limit + 65536)));
|
auto j = parse(unprotect(readFile(path, Limit + 65536)));
|
||||||
return j.at("connection") == configJson(c) ? bytes(j.at("settings")) : Bytes{};
|
auto connection = j.at("connection");
|
||||||
|
if (!connection.contains("keepLocalLayouts")) connection["keepLocalLayouts"] = false;
|
||||||
|
return connection == configJson(c) ? bytes(j.at("settings")) : Bytes{};
|
||||||
}
|
}
|
||||||
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
|
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-2
@@ -7,6 +7,7 @@ namespace ks
|
|||||||
struct SyncConfig
|
struct SyncConfig
|
||||||
{
|
{
|
||||||
bool enabled{};
|
bool enabled{};
|
||||||
|
bool keepLocalLayouts{};
|
||||||
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
||||||
path = L"personal/settings", authMount = L"userpass", username;
|
path = L"personal/settings", authMount = L"userpass", username;
|
||||||
bool operator==(const SyncConfig &) const = default;
|
bool operator==(const SyncConfig &) const = default;
|
||||||
@@ -52,7 +53,7 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r
|
|||||||
const std::vector<SyncChoice> &choices = {});
|
const std::vector<SyncChoice> &choices = {});
|
||||||
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
|
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
|
||||||
Bytes sharedSyncSettings(const Settings &);
|
Bytes sharedSyncSettings(const Settings &);
|
||||||
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &);
|
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &, bool keepLocalLayouts = false);
|
||||||
enum class SyncAction { Automatic, Download, Upload };
|
enum class SyncAction { Automatic, Download, Upload };
|
||||||
struct SyncResult
|
struct SyncResult
|
||||||
{
|
{
|
||||||
@@ -64,7 +65,8 @@ struct SyncResult
|
|||||||
std::vector<SyncConflict> conflicts;
|
std::vector<SyncConflict> conflicts;
|
||||||
};
|
};
|
||||||
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
||||||
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {});
|
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
|
||||||
|
bool keepLocalLayouts = false);
|
||||||
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
|
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
|
||||||
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -4,6 +4,9 @@ namespace
|
|||||||
{
|
{
|
||||||
Json normalizeProfile(Json j)
|
Json normalizeProfile(Json j)
|
||||||
{
|
{
|
||||||
|
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||||
|
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||||
|
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
for (auto &sn : j.at("snippets"))
|
for (auto &sn : j.at("snippets"))
|
||||||
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
||||||
@@ -46,6 +49,10 @@ Json treeProfile(const Json &source)
|
|||||||
}
|
}
|
||||||
Json flatProfile(Json j)
|
Json flatProfile(Json j)
|
||||||
{
|
{
|
||||||
|
auto &replacements = j["autoCorrectReplacements"];
|
||||||
|
for (auto it = replacements.begin(); it != replacements.end();)
|
||||||
|
if (it.value().is_null()) it = replacements.erase(it);
|
||||||
|
else ++it;
|
||||||
Json snippets = Json::array(), deleted = Json::array();
|
Json snippets = Json::array(), deleted = Json::array();
|
||||||
for (const auto &[id, item] : j.at("snippets").items())
|
for (const auto &[id, item] : j.at("snippets").items())
|
||||||
{
|
{
|
||||||
@@ -90,6 +97,8 @@ std::wstring fieldLabel(const std::string &key)
|
|||||||
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
||||||
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
||||||
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
||||||
|
{"autoCorrectReplace", L"Custom automatic replacements"}, {"autoCorrectTypos", L"Dictionary typo correction"},
|
||||||
|
{"autoCorrectReplacements", L"Replacement pairs"},
|
||||||
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
|
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
|
||||||
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
||||||
{"trigger", L"Trigger"}, {"text", L"Text"}};
|
{"trigger", L"Trigger"}, {"text", L"Text"}};
|
||||||
@@ -167,8 +176,9 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
|
|||||||
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
||||||
{
|
{
|
||||||
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
|
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
|
||||||
if (base.empty() && l != r) throw std::runtime_error("First connection: choose Download remote or Upload local.");
|
// With no common history, union independent entries; differing scalar values
|
||||||
auto b = base.empty() ? l : treeProfile(parse(base));
|
// remain on their respective devices until the user resolves the conflict.
|
||||||
|
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
|
||||||
SyncMerge result;
|
SyncMerge result;
|
||||||
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
||||||
std::set<std::string> collisions;
|
std::set<std::string> collisions;
|
||||||
|
|||||||
+48
-8
@@ -83,7 +83,7 @@ void arrangeWindow(HWND w)
|
|||||||
{
|
{
|
||||||
int id = GetDlgCtrlID(child);
|
int id = GetDlgCtrlID(child);
|
||||||
RECT next{scale(w, r.left) - layout->x,
|
RECT next{scale(w, r.left) - layout->x,
|
||||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
scale(w, r.top) - layout->y + ((id >= 110 && id <= 114) || id == 116 ? 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)
|
if (child == layout->stretch)
|
||||||
@@ -730,6 +730,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||||
id = 110;
|
id = 110;
|
||||||
|
if (id == 116)
|
||||||
|
{
|
||||||
|
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items? This also deletes items hidden by the current search.",
|
||||||
|
L"Clear history", MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2) == IDYES)
|
||||||
|
v->app->clearHistory();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
auto entry = selected(w, v);
|
auto entry = selected(w, v);
|
||||||
if (id == 110 || id == 111)
|
if (id == 110 || id == 111)
|
||||||
{
|
{
|
||||||
@@ -1112,6 +1119,25 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||||
draft.autoPrefixChars = value(w, 408);
|
draft.autoPrefixChars = value(w, 408);
|
||||||
draft.autoSuffixChars = value(w, 409);
|
draft.autoSuffixChars = value(w, 409);
|
||||||
|
draft.autoReplace = checked(w, 414);
|
||||||
|
draft.autoTypos = checked(w, 415);
|
||||||
|
draft.autoReplacements.clear();
|
||||||
|
std::wistringstream replacements(lower(value(w, 416)));
|
||||||
|
std::wstring line;
|
||||||
|
auto trim = [](std::wstring text)
|
||||||
|
{
|
||||||
|
auto first = text.find_first_not_of(L" \t\r\n");
|
||||||
|
return first == text.npos ? std::wstring{} : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
|
||||||
|
};
|
||||||
|
while (std::getline(replacements, line))
|
||||||
|
{
|
||||||
|
if (trim(line).empty()) continue;
|
||||||
|
auto equals = line.find(L'=');
|
||||||
|
if (equals == line.npos)
|
||||||
|
throw std::runtime_error("Enter one replacement per line: typo=correction");
|
||||||
|
if (!draft.autoReplacements.emplace(trim(line.substr(0, equals)), trim(line.substr(equals + 1))).second)
|
||||||
|
throw std::runtime_error("Each replacement typo must occur only once");
|
||||||
|
}
|
||||||
if (draft.autoLanguages.size() < 2)
|
if (draft.autoLanguages.size() < 2)
|
||||||
throw std::runtime_error("Select at least two languages");
|
throw std::runtime_error("Select at least two languages");
|
||||||
validateSettings(draft);
|
validateSettings(draft);
|
||||||
@@ -1123,6 +1149,9 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
view->settings->draft.autoMinLength = draft.autoMinLength;
|
view->settings->draft.autoMinLength = draft.autoMinLength;
|
||||||
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
|
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
|
||||||
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
|
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
|
||||||
|
view->settings->draft.autoReplace = draft.autoReplace;
|
||||||
|
view->settings->draft.autoTypos = draft.autoTypos;
|
||||||
|
view->settings->draft.autoReplacements = std::move(draft.autoReplacements);
|
||||||
DestroyWindow(w);
|
DestroyWindow(w);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -1153,8 +1182,8 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
void showAutoOptions(HWND parent, SettingsView *v)
|
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 correction", autoOptionsProc,
|
||||||
560, 680);
|
560, 880);
|
||||||
windowLayoutState(w)->autoOptions = true;
|
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});
|
||||||
@@ -1192,10 +1221,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
value(w, 408, v->draft.autoPrefixChars);
|
value(w, 408, v->draft.autoPrefixChars);
|
||||||
value(w, 409, v->draft.autoSuffixChars);
|
value(w, 409, v->draft.autoSuffixChars);
|
||||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
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);
|
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
||||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
check(w, 414, v->draft.autoReplace);
|
||||||
button(w, L"&OK", IDOK, 326, 632, 100);
|
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
|
||||||
button(w, L"&Cancel", IDCANCEL, 440, 632, 100);
|
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||||
|
std::wstring pairs;
|
||||||
|
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
|
||||||
|
value(w, 416, pairs);
|
||||||
|
checkbox(w, L"Correct spelling &typos using dictionaries (experimental)", 415, 18, 700, 524);
|
||||||
|
check(w, 415, v->draft.autoTypos);
|
||||||
|
label(w, L"Single edits, 4+ letters; ambiguous words are left unchanged.", 18, 730, 525);
|
||||||
|
button(w, L"Open dictionary &folder", 407, 18, 762, 220);
|
||||||
|
label(w, L"Click OK, then Save in Settings. Enable automatic correction there.", 18, 800, 525);
|
||||||
|
button(w, L"&OK", IDOK, 326, 832, 100);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 440, 832, 100);
|
||||||
RECT initial{};
|
RECT initial{};
|
||||||
GetWindowRect(w, &initial);
|
GetWindowRect(w, &initial);
|
||||||
initial.right += scale(w, 200);
|
initial.right += scale(w, 200);
|
||||||
@@ -1618,6 +1657,7 @@ void showHistory(App &app, bool visible)
|
|||||||
button(w, L"P&in / unpin", 112, 222, 452, 110);
|
button(w, L"P&in / unpin", 112, 222, 452, 110);
|
||||||
button(w, L"&Delete", 113, 340, 452, 90);
|
button(w, L"&Delete", 113, 340, 452, 90);
|
||||||
button(w, L"&Open item", 114, 438, 452, 100);
|
button(w, L"&Open item", 114, 438, 452, 100);
|
||||||
|
button(w, L"Clear &history…", 116, 546, 452, 150);
|
||||||
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
||||||
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
||||||
}
|
}
|
||||||
@@ -1672,7 +1712,7 @@ void showSettings(App &app, bool visible)
|
|||||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||||
}
|
}
|
||||||
checkbox(w, L"Open editor after screen capt&ure", 209, 420, 224, 340);
|
checkbox(w, L"Open editor after screen capt&ure", 209, 420, 224, 340);
|
||||||
checkbox(w, L"Automatically correct layout (e&xperimental)", 207, 18, 210, 395);
|
checkbox(w, L"Automatic correction (e&xperimental)", 207, 18, 210, 395);
|
||||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||||
18, 262, 470, 172);
|
18, 262, 470, 172);
|
||||||
|
|||||||
+52
-11
@@ -1,9 +1,29 @@
|
|||||||
#include "ui_sync_conflicts.inl"
|
#include "ui_sync_conflicts.inl"
|
||||||
struct SyncView { HWND parent; App *app; };
|
struct SyncView { HWND parent; App *app; };
|
||||||
|
void updateSyncControls(HWND w, App &app)
|
||||||
|
{
|
||||||
|
bool enabled = checked(w, 601);
|
||||||
|
bool savedEnabled = loadSyncConfig(app.data).enabled;
|
||||||
|
for (int id : {602, 603, 604, 605, 606, 607, 610, 611, 613})
|
||||||
|
EnableWindow(GetDlgItem(w, id), enabled);
|
||||||
|
EnableWindow(GetDlgItem(w, 612), enabled && savedEnabled);
|
||||||
|
EnableWindow(GetDlgItem(w, IDOK), enabled || savedEnabled);
|
||||||
|
value(w, IDOK, enabled ? L"&Save / Sign in / Sync" : L"&Save");
|
||||||
|
std::wstring status;
|
||||||
|
if (!enabled)
|
||||||
|
status = savedEnabled ? L"Click Save to disable synchronization." :
|
||||||
|
L"Synchronization is disabled. Enable it above to configure and sync.";
|
||||||
|
else if (!savedEnabled)
|
||||||
|
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
|
||||||
|
else
|
||||||
|
status = app.syncCommand();
|
||||||
|
if (value(w, 608) != status) value(w, 608, status);
|
||||||
|
}
|
||||||
SyncConfig syncFields(HWND w)
|
SyncConfig syncFields(HWND w)
|
||||||
{
|
{
|
||||||
SyncConfig c;
|
SyncConfig c;
|
||||||
c.enabled = checked(w, 601);
|
c.enabled = checked(w, 601);
|
||||||
|
c.keepLocalLayouts = checked(w, 613);
|
||||||
c.server = value(w, 602);
|
c.server = value(w, 602);
|
||||||
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
||||||
c.username = lower(value(w, 603)); c.mount = value(w, 605);
|
c.username = lower(value(w, 603)); c.mount = value(w, 605);
|
||||||
@@ -19,12 +39,19 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
if (msg == WM_TIMER)
|
if (msg == WM_TIMER)
|
||||||
{
|
{
|
||||||
value(w, 608, v->app->syncCommand());
|
updateSyncControls(w, *v->app);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (msg == WM_COMMAND)
|
if (msg == WM_COMMAND)
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
|
if (id == 601 && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((id == IDOK || id == 610 || id == 611 || id == 612) &&
|
||||||
|
!IsWindowEnabled(GetDlgItem(w, id))) return 0;
|
||||||
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
|
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
|
||||||
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
if (id == 609)
|
if (id == 609)
|
||||||
@@ -35,18 +62,28 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
forgetSyncPassword(c);
|
forgetSyncPassword(c);
|
||||||
check(w, 601, false);
|
check(w, 601, false);
|
||||||
value(w, 604, L"");
|
value(w, 604, L"");
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
||||||
KillTimer(w, 1);
|
KillTimer(w, 1);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == IDOK || id == 610 || id == 611)
|
if (id == IDOK || id == 610 || id == 611)
|
||||||
{
|
{
|
||||||
|
if (!checked(w, 601))
|
||||||
|
{
|
||||||
|
if (id != IDOK) return 0;
|
||||||
|
auto c = loadSyncConfig(v->app->data);
|
||||||
|
c.enabled = false;
|
||||||
|
saveSyncConfig(v->app->data, c);
|
||||||
|
value(w, 604, L"");
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (id != IDOK && MessageBoxW(w, id == 610 ?
|
if (id != IDOK && MessageBoxW(w, id == 610 ?
|
||||||
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
|
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
|
||||||
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
|
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
|
||||||
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
||||||
auto c = syncFields(w);
|
auto c = syncFields(w);
|
||||||
if (id != IDOK && !c.enabled) throw std::runtime_error("Enable synchronization first.");
|
|
||||||
if (c.enabled) validateSyncConfig(c);
|
if (c.enabled) validateSyncConfig(c);
|
||||||
auto password = value(w, 604);
|
auto password = value(w, 604);
|
||||||
try { if (!password.empty()) saveSyncPassword(c, password); }
|
try { if (!password.empty()) saveSyncPassword(c, password); }
|
||||||
@@ -55,6 +92,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
value(w, 604, L"");
|
value(w, 604, L"");
|
||||||
saveSyncConfig(v->app->data, c);
|
saveSyncConfig(v->app->data, c);
|
||||||
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
|
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
SetTimer(w, 1, 1000, nullptr);
|
SetTimer(w, 1, 1000, nullptr);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -80,7 +118,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
void showSyncOptions(HWND parent, App &app)
|
void showSyncOptions(HWND parent, App &app)
|
||||||
{
|
{
|
||||||
auto c = loadSyncConfig(app.data);
|
auto c = loadSyncConfig(app.data);
|
||||||
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 640);
|
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 672);
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
|
||||||
checkbox(w, L"&Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
|
checkbox(w, L"&Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
|
||||||
@@ -93,15 +131,18 @@ void showSyncOptions(HWND parent, App &app)
|
|||||||
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
|
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
|
||||||
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
|
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
|
||||||
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
|
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
|
||||||
control(w, L"STATIC", L"", 0, 608, 18, 362, 622, 76);
|
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||||
value(w, 608, app.syncCommand());
|
value(w, 608, app.syncCommand());
|
||||||
button(w, L"&Save / Sign in / Sync", IDOK, 18, 452, 200);
|
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
|
||||||
button(w, L"&Download remote", 610, 230, 452, 195);
|
check(w, 613, c.keepLocalLayouts);
|
||||||
button(w, L"&Upload local", 611, 437, 452, 203);
|
button(w, L"&Save / Sign in / Sync", IDOK, 18, 484, 200);
|
||||||
button(w, L"Resolve &individual conflicts...", 612, 18, 498, 300);
|
button(w, L"&Download remote", 610, 230, 484, 195);
|
||||||
label(w, L"Background check: every 10 minutes", 330, 502, 310);
|
button(w, L"&Upload local", 611, 437, 484, 203);
|
||||||
button(w, L"Disconnect / &forget password", 609, 18, 550, 290);
|
button(w, L"Resolve &individual conflicts...", 612, 18, 530, 300);
|
||||||
button(w, L"&Close", IDCANCEL, 540, 550, 100);
|
label(w, L"Background check: every 10 minutes", 330, 534, 310);
|
||||||
|
button(w, L"Disconnect / &forget password", 609, 18, 582, 290);
|
||||||
|
button(w, L"&Close", IDCANCEL, 540, 582, 100);
|
||||||
|
updateSyncControls(w, app);
|
||||||
EnableWindow(parent, FALSE);
|
EnableWindow(parent, FALSE);
|
||||||
SetTimer(w, 1, 1000, nullptr);
|
SetTimer(w, 1, 1000, nullptr);
|
||||||
showRequestedWindow(w); SetForegroundWindow(w);
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.27";
|
inline constexpr wchar_t AppVersion[] = L"0.6.30";
|
||||||
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;
|
||||||
|
|||||||
+48
-5
@@ -599,6 +599,9 @@ int main(int argc, char **argv) {
|
|||||||
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
||||||
app.resetInput();
|
app.resetInput();
|
||||||
app.handleKeyboard(event('A',0x1e),true,0);
|
app.handleKeyboard(event('A',0x1e),true,0);
|
||||||
|
if (app.run.keys!=L"a" && app.run.keys!=L"A")
|
||||||
|
std::cerr << "Input fixture foreground=" << (uintptr_t)GetForegroundWindow()
|
||||||
|
<< " recorded=" << ks::utf8(app.run.keys) << '\n';
|
||||||
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
|
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
|
||||||
auto keys=app.run.keys; auto version=app.inputVersion.load();
|
auto keys=app.run.keys; auto version=app.inputVersion.load();
|
||||||
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
||||||
@@ -656,6 +659,23 @@ int main(int argc, char **argv) {
|
|||||||
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
|
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
|
||||||
"sync password is masked");
|
"sync password is masked");
|
||||||
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
|
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
|
||||||
|
for(int id:{602,603,604,605,606,607,610,611,612,613,IDOK})
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(syncWindow,id)),"disabled synchronization disables dependent controls");
|
||||||
|
require(IsWindowEnabled(GetDlgItem(syncWindow,601)) && IsWindowEnabled(GetDlgItem(syncWindow,609)) &&
|
||||||
|
IsWindowEnabled(GetDlgItem(syncWindow,IDCANCEL)),"enable, forget password and close remain available");
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||||
|
require(!FindWindowW(L"KSwitcher.SyncConflicts",nullptr),"disabled conflict action is ignored");
|
||||||
|
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_CHECKED,0);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||||
|
for(int id:{602,603,604,605,606,607,610,611,613,IDOK})
|
||||||
|
require(IsWindowEnabled(GetDlgItem(syncWindow,id)),"enabling synchronization immediately enables connection controls");
|
||||||
|
require(!ks::loadSyncConfig(app.data).enabled,"checking enable alone does not connect or persist");
|
||||||
|
auto enabledSync=ks::loadSyncConfig(app.data);
|
||||||
|
enabledSync.enabled=true;
|
||||||
|
enabledSync.server=L"https://sync.example.test";
|
||||||
|
enabledSync.username=L"test";
|
||||||
|
ks::saveSyncConfig(app.data,enabledSync);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||||
ks::InputRegressionAccess::sampleConflicts(app);
|
ks::InputRegressionAccess::sampleConflicts(app);
|
||||||
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||||
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
||||||
@@ -678,7 +698,7 @@ int main(int argc, char **argv) {
|
|||||||
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
|
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
|
||||||
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
|
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
|
||||||
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,IDOK,IDCANCEL}) {
|
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,613,IDOK,IDCANCEL}) {
|
||||||
RECT control{},client{};
|
RECT control{},client{};
|
||||||
GetWindowRect(GetDlgItem(syncWindow,id),&control);
|
GetWindowRect(GetDlgItem(syncWindow,id),&control);
|
||||||
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
|
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
|
||||||
@@ -686,8 +706,12 @@ int main(int argc, char **argv) {
|
|||||||
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||||
"sync controls fit dialog");
|
"sync controls fit dialog");
|
||||||
}
|
}
|
||||||
|
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_UNCHECKED,0);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||||
|
require(IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save remains available to persist disabling sync");
|
||||||
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
|
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
|
||||||
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
|
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save disables after disabled state is persisted");
|
||||||
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||||
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||||
@@ -704,21 +728,31 @@ int main(int argc, char **argv) {
|
|||||||
GetWindowRect(autoOptions,&original);
|
GetWindowRect(autoOptions,&original);
|
||||||
// Use a client size above the scrolling baseline at every DPI.
|
// Use a client size above the scrolling baseline at every DPI.
|
||||||
UINT dpi=GetDpiForWindow(autoOptions);
|
UINT dpi=GetDpiForWindow(autoOptions);
|
||||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(1100,dpi,96),
|
||||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||||
|
RECT clientBefore{}; GetClientRect(autoOptions,&clientBefore);
|
||||||
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||||
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||||
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
RECT clientAfter{}; GetClientRect(autoOptions,&clientAfter);
|
||||||
|
// Windows can constrain oversized windows to the current desktop.
|
||||||
|
// Check layout against the actual available client space.
|
||||||
|
int extraBeforeX=std::max(0,int(clientBefore.right)-MulDiv(560,dpi,96));
|
||||||
|
int extraAfterX=std::max(0,int(clientAfter.right)-MulDiv(560,dpi,96));
|
||||||
|
int extraY=std::max(0,int(clientAfter.bottom)-MulDiv(880,dpi,96))-
|
||||||
|
std::max(0,int(clientBefore.bottom)-MulDiv(880,dpi,96));
|
||||||
for(int i=0;i<3;++i) {
|
for(int i=0;i<3;++i) {
|
||||||
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||||
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
int extraX=(extraAfterX*(i+1)/3-extraAfterX*i/3)-
|
||||||
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
(extraBeforeX*(i+1)/3-extraBeforeX*i/3);
|
||||||
|
require(after[i].right-after[i].left==before[i].right-before[i].left+extraX &&
|
||||||
|
after[i].bottom-after[i].top==before[i].bottom-before[i].top+extraY,
|
||||||
"dictionary columns share extra width and receive extra height");
|
"dictionary columns share extra width and receive extra height");
|
||||||
}
|
}
|
||||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
||||||
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
|
require(buttonAfter.left-buttonBefore.left==extraAfterX-extraBeforeX && buttonAfter.top-buttonBefore.top==extraY,
|
||||||
"auto-correction buttons follow bottom-right corner");
|
"auto-correction buttons follow bottom-right corner");
|
||||||
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||||
after[2].bottom<buttonAfter.top,
|
after[2].bottom<buttonAfter.top,
|
||||||
@@ -735,6 +769,9 @@ int main(int argc, char **argv) {
|
|||||||
"personal dictionary field fits options window");
|
"personal dictionary field fits options window");
|
||||||
}
|
}
|
||||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
||||||
|
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
|
||||||
|
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
||||||
|
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
@@ -744,6 +781,12 @@ int main(int argc, char **argv) {
|
|||||||
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
||||||
require(std::wstring(personalWords)==L"релізик, словечко",
|
require(std::wstring(personalWords)==L"релізик, словечко",
|
||||||
"personal dictionary normalizes case, newlines and duplicate words");
|
"personal dictionary normalizes case, newlines and duplicate words");
|
||||||
|
wchar_t pairs[128]{};
|
||||||
|
GetDlgItemTextW(autoOptions,416,pairs,128);
|
||||||
|
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
|
||||||
|
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
||||||
|
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
||||||
|
"typo options and normalized replacement pairs survive reopening");
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
||||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||||
ks::showHistory(app,false);
|
ks::showHistory(app,false);
|
||||||
|
|||||||
+95
-1
@@ -9,6 +9,7 @@ namespace ks
|
|||||||
{
|
{
|
||||||
struct InputRegressionAccess
|
struct InputRegressionAccess
|
||||||
{
|
{
|
||||||
|
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||||
static bool deferring(App &app)
|
static bool deferring(App &app)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(app.stateMutex);
|
std::lock_guard lock(app.stateMutex);
|
||||||
@@ -23,6 +24,7 @@ HWND host{}, field{};
|
|||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
bool captured = false, failed = false;
|
bool captured = false, failed = false;
|
||||||
bool conversionOnly = false;
|
bool conversionOnly = false;
|
||||||
|
bool typosOnly = false;
|
||||||
int pasteBehavior = 0;
|
int pasteBehavior = 0;
|
||||||
int enterPresses = 0;
|
int enterPresses = 0;
|
||||||
int copyFailuresRemaining = 0, copyRequests = 0;
|
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||||
@@ -79,6 +81,7 @@ LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_P
|
|||||||
}
|
}
|
||||||
if (message == WM_PASTE && pasteBehavior)
|
if (message == WM_PASTE && pasteBehavior)
|
||||||
{
|
{
|
||||||
|
if (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
|
||||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -370,6 +373,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||||||
"blocked paste records original text remaining in the actual editor");
|
"blocked paste records original text remaining in the actual editor");
|
||||||
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
|
||||||
pasteBehavior = 2;
|
pasteBehavior = 2;
|
||||||
prepare(L"ghbdtn", true);
|
prepare(L"ghbdtn", true);
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -380,6 +384,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||||
"unexpected editor content records readback mismatch");
|
"unexpected editor content records readback mismatch");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
|
||||||
pasteBehavior = 0;
|
pasteBehavior = 0;
|
||||||
prepare(L"привет", true);
|
prepare(L"привет", true);
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -427,8 +432,41 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 47:
|
case 47:
|
||||||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||||
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||||
|
app->settings.writeLog = true;
|
||||||
|
prepare(L"prefix wrong ", false);
|
||||||
|
record(L"other ");
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 48:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"prefix wrong " && !rows.empty() &&
|
||||||
|
rows.back().outcome == L"recent-verification-mismatch",
|
||||||
|
"diagnostic mismatch does not replace unverified text");
|
||||||
|
auto details = rows.back().details;
|
||||||
|
require(details.find(L"selection-mismatch") != details.npos &&
|
||||||
|
details.find(L"Expected selection:\r\nother ") != details.npos &&
|
||||||
|
details.find(L"Read back:\r\nwrong ") != details.npos,
|
||||||
|
"failed verification includes expected and copied text in exported details");
|
||||||
|
require(details.find(L"Copy begin number=") != details.npos &&
|
||||||
|
details.find(L"Copy result number=") != details.npos &&
|
||||||
|
details.find(L"Selection initial match=0") != details.npos &&
|
||||||
|
details.find(L"SelectPrevious") != details.npos,
|
||||||
|
"incident includes clipboard and selection stages");
|
||||||
|
pasteBehavior = 3;
|
||||||
|
prepare(L"=nhtdjuf", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
stage = 49;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 49:
|
||||||
|
{
|
||||||
|
require(text() == L"=тревога" && pasteBehavior == 0,
|
||||||
|
"native Edit retries one ignored paste only while original remains selected");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard");
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 30:
|
case 30:
|
||||||
case 32:
|
case 32:
|
||||||
case 34:
|
case 34:
|
||||||
@@ -597,6 +635,61 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
case 22:
|
case 22:
|
||||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||||
|
app->settings.autoReplace = true;
|
||||||
|
app->settings.autoReplacements = {{L"helo", L"hello"}};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"(Helo)! ", false);
|
||||||
|
record(L"(Helo)! ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 23:
|
||||||
|
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
|
||||||
|
app->transform(ks::Undo);
|
||||||
|
break;
|
||||||
|
case 24:
|
||||||
|
require(text() == L"(Helo)! ", "Undo restores explicit typo replacement");
|
||||||
|
app->settings.autoReplace = false;
|
||||||
|
app->settings.autoTypos = true;
|
||||||
|
app->settings.autoWords[L"en"].push_back(L"kswitcher");
|
||||||
|
// Use a personal target to avoid bundled dictionary ambiguity.
|
||||||
|
prepare(L"kswithcer ", false);
|
||||||
|
record(L"kswithcer ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 25:
|
||||||
|
require(text() == L"kswitcher ", "dictionary typo correction replaces text in the current layout");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "typo correction restores clipboard");
|
||||||
|
app->transform(ks::Undo);
|
||||||
|
break;
|
||||||
|
case 26:
|
||||||
|
require(text() == L"kswithcer ", "Undo restores dictionary typo correction");
|
||||||
|
record(L"kswithcer ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 27:
|
||||||
|
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
||||||
|
app->settings.autoReplace = true;
|
||||||
|
app->settings.autoReplacements = {{L"tehh", L"the"}};
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
prepare(L"", false);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"TEHH X"))
|
||||||
|
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(),
|
||||||
|
"custom typo and following word submitted without a pause");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 28:
|
||||||
|
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
|
||||||
|
app->settings.autoReplace = false;
|
||||||
|
app->settings.autoTypos = false;
|
||||||
|
if (typosOnly) { finish(); break; }
|
||||||
stage = 30;
|
stage = 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -621,6 +714,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
int main(int argc, char **argv)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||||||
|
typosOnly = argc > 1 && std::string(argv[1]) == "--typos-only";
|
||||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
auto root = std::filesystem::temp_directory_path() /
|
auto root = std::filesystem::temp_directory_path() /
|
||||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
@@ -776,7 +870,7 @@ int main(int argc, char **argv)
|
|||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
deadline = GetTickCount64() + 30000;
|
||||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||||
stage = 20;
|
stage = 20;
|
||||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||||
stage = 30;
|
stage = 30;
|
||||||
|
|||||||
+79
-1
@@ -54,6 +54,10 @@ int main()
|
|||||||
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
||||||
saveSyncConfig(root, config);
|
saveSyncConfig(root, config);
|
||||||
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
|
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
|
||||||
|
auto partialConfig = config; partialConfig.keepLocalLayouts = true;
|
||||||
|
saveSyncConfig(root, partialConfig);
|
||||||
|
require(loadSyncConfig(root).keepLocalLayouts, "partial sync mode survives restart");
|
||||||
|
saveSyncConfig(root, config);
|
||||||
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
||||||
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
||||||
Settings local;
|
Settings local;
|
||||||
@@ -84,9 +88,49 @@ int main()
|
|||||||
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
||||||
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
||||||
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
|
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
|
||||||
rejects([&] { mergeSyncSettings({}, bytes(left), base); }, "first sync needs explicit choice");
|
require(parse(mergeSyncSettings({}, bytes(left), base))["snippets"].size() == 2,
|
||||||
|
"first sync merges independent additions without choosing a whole profile");
|
||||||
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||||
|
l["autoCorrectNever"].push_back("localword");
|
||||||
|
r["autoCorrectNever"].push_back("remoteword");
|
||||||
|
auto plan = mergeSyncProfile({}, bytes(l), bytes(r));
|
||||||
|
require(plan.conflicts.size() == 1 && parse(plan.local)["autoCorrectNever"].size() == 2 &&
|
||||||
|
parse(plan.remote)["autoCorrectNever"].size() == 2,
|
||||||
|
"first sync merges lists while preserving scalar conflicts");
|
||||||
|
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
||||||
|
"first sync never guesses between different scalar values");
|
||||||
|
auto again = mergeSyncProfile(plan.base, plan.local, plan.remote);
|
||||||
|
require(again.conflicts.size() == 1, "first-sync conflicts survive saving the ancestor");
|
||||||
|
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {{plan.conflicts[0], false}});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.local)["autoCorrectMinLength"] == 5,
|
||||||
|
"first-sync conflict can be resolved independently");
|
||||||
|
}
|
||||||
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["autoCorrectReplacements"]["helo"] = "hello";
|
||||||
|
r["autoCorrectReplacements"]["wrold"] = "world";
|
||||||
|
auto paired = mergeSyncSettings(base, bytes(l), bytes(r));
|
||||||
|
require(parse(paired)["autoCorrectReplacements"].size() == 2, "independent replacement pairs merge");
|
||||||
|
l = r = parse(paired);
|
||||||
|
l["autoCorrectReplacements"].erase("helo");
|
||||||
|
r["autoCorrectReplacements"]["wrold"] = "worlds";
|
||||||
|
auto removed = mergeSyncSettings(paired, bytes(l), bytes(r));
|
||||||
|
auto restored = restoreSyncSettings(removed, local, {});
|
||||||
|
require(restored.autoReplacements.size() == 1 && restored.autoReplacements.at(L"wrold") == L"worlds",
|
||||||
|
"replacement deletion merges with an independent edit and remains loadable");
|
||||||
|
r["autoCorrectReplacements"]["helo"] = "help";
|
||||||
|
require(mergeSyncProfile(paired, bytes(l), bytes(r)).conflicts.size() == 1,
|
||||||
|
"replacement deletion versus edit is a conflict");
|
||||||
|
auto legacy = j;
|
||||||
|
for (auto key : {"autoCorrectReplace", "autoCorrectTypos", "autoCorrectReplacements"}) legacy.erase(key);
|
||||||
|
auto migrated = restoreSyncSettings(bytes(legacy), local, {});
|
||||||
|
require(!migrated.autoReplace && !migrated.autoTypos && migrated.autoReplacements.empty(),
|
||||||
|
"older synchronized profiles default new typo modes off");
|
||||||
|
}
|
||||||
{
|
{
|
||||||
auto l = j, r = j;
|
auto l = j, r = j;
|
||||||
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||||
@@ -173,6 +217,40 @@ int main()
|
|||||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
||||||
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
||||||
provider.race = false;
|
provider.race = false;
|
||||||
|
{
|
||||||
|
auto remoteProfile = j;
|
||||||
|
remoteProfile["layouts"] = Json::array({"win:00000422"});
|
||||||
|
remoteProfile["layoutHotkeys"] = Json::array({{{"layoutId", "win:00000422"}, {"hotkey", "Ctrl+Alt+9"}}});
|
||||||
|
remoteProfile["autoCorrectNever"].push_back("remoteword");
|
||||||
|
FakeProvider partial;
|
||||||
|
partial.remote = {3, bytes(remoteProfile)};
|
||||||
|
auto validate = [&](const Bytes &b) { restoreSyncSettings(b, local, {}, true); };
|
||||||
|
auto result = synchronize(partial, {}, base, SyncAction::Automatic, validate, {}, true);
|
||||||
|
auto restored = restoreSyncSettings(result.settings, local, {}, true);
|
||||||
|
require(restored.cycle == local.cycle && restored.direct == local.direct &&
|
||||||
|
restored.autoNever == std::vector<std::wstring>{L"remoteword"},
|
||||||
|
"partial sync restores other settings and keeps local layout configuration");
|
||||||
|
auto next = sharedSyncSettings(restored);
|
||||||
|
result = synchronize(partial, result.base, next, SyncAction::Automatic, validate, {}, true);
|
||||||
|
require(parse(partial.remote.settings)["layouts"] == remoteProfile["layouts"] &&
|
||||||
|
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
|
||||||
|
"repeated partial sync never deletes unavailable server layouts or shortcuts");
|
||||||
|
auto changed = parse(next); changed["autoCorrectMinLength"] = 7;
|
||||||
|
result = synchronize(partial, result.base, bytes(changed), SyncAction::Upload, validate, {}, true);
|
||||||
|
require(parse(partial.remote.settings)["autoCorrectMinLength"] == 7 &&
|
||||||
|
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
|
||||||
|
"explicit partial upload preserves server layouts too");
|
||||||
|
auto downloaded = synchronize(partial, result.base, base, SyncAction::Download, validate, {}, true);
|
||||||
|
auto downloadedSettings = restoreSyncSettings(downloaded.settings, local, {}, true);
|
||||||
|
require(downloadedSettings.autoMinLength == 7 && downloadedSettings.cycle == local.cycle &&
|
||||||
|
downloadedSettings.direct == local.direct, "partial Download applies other settings without replacing local layouts");
|
||||||
|
try { restoreSyncSettings(bytes(remoteProfile), local, {}); require(false, "full sync must identify missing layouts"); }
|
||||||
|
catch (const std::runtime_error &e) {
|
||||||
|
std::string message = e.what();
|
||||||
|
require(message.find("win:00000422") != message.npos && message.find("Keep this PC's layouts") != message.npos,
|
||||||
|
"missing-layout error identifies IDs and offers partial sync");
|
||||||
|
}
|
||||||
|
}
|
||||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
||||||
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
||||||
require(provider.writes == 1, "invalid data not uploaded");
|
require(provider.writes == 1, "invalid data not uploaded");
|
||||||
|
|||||||
+58
-2
@@ -111,8 +111,8 @@ int main(int argc, char **argv)
|
|||||||
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
|
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
|
||||||
"context expires after thirty seconds");
|
"context expires after thirty seconds");
|
||||||
diagnostics.record(L"oldest-context", false, 30600);
|
diagnostics.record(L"oldest-context", false, 30600);
|
||||||
for (int i = 0; i < 40; ++i)
|
for (int i = 0; i < 128; ++i)
|
||||||
diagnostics.record(L"bounded-context", false, 30600 + i);
|
diagnostics.record(L"bounded-context", false, 30600);
|
||||||
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
||||||
"context buffer has a fixed size");
|
"context buffer has a fixed size");
|
||||||
diagnostics.record(L"disabled-context", false, 30800);
|
diagnostics.record(L"disabled-context", false, 30800);
|
||||||
@@ -217,6 +217,13 @@ int main(int argc, char **argv)
|
|||||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||||
autoSettings.cycle = {L"en", L"uk"};
|
autoSettings.cycle = {L"en", L"uk"};
|
||||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
||||||
|
{
|
||||||
|
auto options = autoSettings;
|
||||||
|
options.autoTypos = true;
|
||||||
|
auto typo = corrector.suggest(L"првиіт ", 2, layouts, options);
|
||||||
|
require(typo && typo->text == L"привіт " && typo->target == 2,
|
||||||
|
"bundled Ukrainian dictionary corrects a common transposition");
|
||||||
|
}
|
||||||
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
||||||
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
||||||
"dictionary corrects Ukrainian and retains trailing space");
|
"dictionary corrects Ukrainian and retains trailing space");
|
||||||
@@ -497,6 +504,45 @@ int main(int argc, char **argv)
|
|||||||
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
||||||
require(correction && correction->text == L"hello ",
|
require(correction && correction->text == L"hello ",
|
||||||
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
||||||
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "spelling correction defaults off");
|
||||||
|
options.autoTypos = true;
|
||||||
|
for (auto typo : {L"hlelo ", L"helo ", L"helllo ", L"hezlo "})
|
||||||
|
{
|
||||||
|
correction = edited.suggest(typo, 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"hello " && correction->target == 0,
|
||||||
|
"single transposition, deletion, insertion and substitution retain the source layout");
|
||||||
|
}
|
||||||
|
correction = edited.suggest(L"(Hlelo), ", 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"(Hello), ", "typos retain case and punctuation");
|
||||||
|
for (auto text : {L"hello ", L"HLELO ", L"hLelo ", L"h3llo ", L"h.llo ", L"hlo ", L"hello@example.com "})
|
||||||
|
require(!edited.suggest(text, 0, layouts, options), "known or unsafe typo input is unchanged");
|
||||||
|
options.autoWords[L"en"] = {L"hella"};
|
||||||
|
require(!edited.suggest(L"hell ", 0, layouts, options), "personal dictionary alternatives create ambiguity");
|
||||||
|
options.autoWords.clear();
|
||||||
|
options.autoReplacements = {{L"hlelo", L"greeting"}, {L"hello", L"greetings"}, {L"првиіт", L"привіт"}};
|
||||||
|
options.autoReplace = true;
|
||||||
|
correction = edited.suggest(L"Hlelo! ", 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"Greeting! ", "explicit replacements override spelling guesses");
|
||||||
|
correction = edited.suggest(L"hello ", 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"greetings ", "explicit replacements can override known words");
|
||||||
|
correction = edited.suggest(L"Првиіт ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"Привіт " && correction->target == 2,
|
||||||
|
"Ukrainian replacements work without dictionary membership");
|
||||||
|
options.autoNever = {L"hlelo"};
|
||||||
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||||
|
options.autoNever.clear();
|
||||||
|
edited.reject(L"(Hlelo)! ", options);
|
||||||
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
||||||
|
options.autoReplace = false;
|
||||||
|
options.autoWords[L"uk"] = {L"привіт"};
|
||||||
|
correction = edited.suggest(L"првиіт ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"привіт ", "Ukrainian personal words are typo targets");
|
||||||
|
options.cycle = {L"en"};
|
||||||
|
require(!edited.suggest(L"првиіт ", 2, layouts, options), "disabled source layouts do not correct typos");
|
||||||
|
put(L"en.txt", L"hello\t1\nhella\t99999\n");
|
||||||
|
ks::AutoCorrector ambiguous;
|
||||||
|
ambiguous.configure(folder, {L"en", L"uk"});
|
||||||
|
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
||||||
}
|
}
|
||||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||||
@@ -542,6 +588,8 @@ int main(int argc, char **argv)
|
|||||||
settings.writeLog = false;
|
settings.writeLog = false;
|
||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
|
settings.autoReplace = settings.autoTypos = true;
|
||||||
|
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
||||||
settings.autoMaxRank = 10000;
|
settings.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
@@ -554,6 +602,14 @@ int main(int argc, char **argv)
|
|||||||
ks::saveSettings(temp / L"settings.json", settings);
|
ks::saveSettings(temp / L"settings.json", settings);
|
||||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||||
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
||||||
|
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
||||||
|
"typo options and Unicode pairs survive settings roundtrip");
|
||||||
|
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}})
|
||||||
|
{
|
||||||
|
auto invalid = settings;
|
||||||
|
invalid.autoReplacements = {{pair.first, pair.second}};
|
||||||
|
rejects([&] { ks::validateSettings(invalid); }, "invalid replacement pairs rejected");
|
||||||
|
}
|
||||||
auto invalidWords = settings;
|
auto invalidWords = settings;
|
||||||
invalidWords.autoWords[L"uk"] = {L"hello"};
|
invalidWords.autoWords[L"uk"] = {L"hello"};
|
||||||
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
||||||
|
|||||||
Reference in New Issue
Block a user