Compare commits
5 Commits
release-v0.6.26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b51d341 | |||
| 0184b837f4 | |||
| ca553a5e7d | |||
| 55993c0f90 | |||
| 738c81dce2 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.26 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.30 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
|
||||
@@ -16,6 +16,11 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||
Double-click the tray icon to open Settings, the bold default menu item.
|
||||
Menus support access keys: press the underlined letter while the menu is open (for example,
|
||||
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
||||
to activate a button, toggle a checkbox or focus a labeled field. Press Alt to reveal
|
||||
the underlines when Windows hides them. Shortcut-capture fields still record Alt combinations;
|
||||
press Tab to leave a shortcut field before using form access keys.
|
||||
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||
@@ -266,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.
|
||||
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
|
||||
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.
|
||||
- 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.
|
||||
@@ -316,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,
|
||||
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**
|
||||
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
|
||||
@@ -331,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
|
||||
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.
|
||||
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
|
||||
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
|
||||
@@ -399,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
|
||||
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.
|
||||
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
|
||||
@@ -409,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
|
||||
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.
|
||||
- **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;
|
||||
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
||||
|
||||
@@ -448,8 +476,14 @@ Deleted server versions are not silently recreated.
|
||||
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
||||
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
||||
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
||||
history, screenshots and dictionary files are never uploaded. Missing keyboard layouts block
|
||||
applying a remote profile until those Windows layouts are installed, preventing lost bindings.
|
||||
history, screenshots and dictionary files are never uploaded. Full synchronization identifies
|
||||
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.
|
||||
|
||||
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||
@@ -501,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
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
Binary file not shown.
+9
-11
@@ -1,25 +1,23 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.26 — 2026-11-02
|
||||
## v0.6.30 — 2026-11-02
|
||||
|
||||
## Changes
|
||||
- Settings → Sync… is available to everyone. Connect your own OpenBao KV v2 with a username and password; synchronization is disabled by default and no server is built in.
|
||||
- Synchronize at startup, approximately two seconds after saving settings, and every 10 minutes in the background. Local operation continues offline.
|
||||
- Merge independent settings changes and resolve conflicts individually, with local/server values shown side by side. Unresolved values stay pending while independent changes synchronize. Every server write uses CAS to protect concurrent changes.
|
||||
- Snippets have stable IDs and retained deletion records. Independent snippets, renames and text edits can merge; deletion versus editing and duplicate shortcuts/triggers receive explicit conflict handling.
|
||||
- Personal dictionaries, exclusions, shortcuts and layout preferences synchronize. Startup, history, local paths, connection details and credentials stay on the PC. Passwords use Windows Credential Manager; cached sync state uses DPAPI.
|
||||
- Existing profiles migrate to sync envelope version 2. Update every participating PC before using the new synchronization behavior.
|
||||
- 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.
|
||||
- A previous-result verification mismatch clears stale repeat-conversion state.
|
||||
- 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.
|
||||
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
|
||||
|
||||
## Validation
|
||||
91 sync/protocol/conflict checks, 423 native regressions, 510 input/UI checks, screenshot editor and PNG save regressions, shared wheel handling, notification registration, 48 updater checks and updater progress checks. Initial upload and personal-dictionary synchronization were confirmed against the user's OpenBao. Two-physical-PC production testing is still pending.
|
||||
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
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -28,4 +26,4 @@ Build and package:
|
||||
.\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 @@
|
||||
99a863ddb82d3ec868d874ba5986d6552ab8e5b2fd61d0e227577b35f2657383 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"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,26,0
|
||||
PRODUCTVERSION 0,6,26,0
|
||||
FILEVERSION 0,6,30,0
|
||||
PRODUCTVERSION 0,6,30,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.26"
|
||||
VALUE "FileVersion", "0.6.30"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+145
-24
@@ -73,6 +73,8 @@ class Transaction
|
||||
Clip snapshot;
|
||||
DWORD expected{};
|
||||
bool captured{};
|
||||
mutable bool invalidLogged{};
|
||||
unsigned copyNumber{};
|
||||
std::wstring adopted;
|
||||
|
||||
public:
|
||||
@@ -108,8 +110,18 @@ class Transaction
|
||||
}
|
||||
bool valid() const
|
||||
{
|
||||
return target && IsWindow(target) && GetForegroundWindow() == target &&
|
||||
app.inputVersion.load() == version;
|
||||
auto foreground = GetForegroundWindow();
|
||||
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()
|
||||
{
|
||||
@@ -144,10 +156,14 @@ class Transaction
|
||||
}
|
||||
std::wstring copy()
|
||||
{
|
||||
const auto started = GetTickCount64();
|
||||
const auto number = ++copyNumber;
|
||||
if (!valid())
|
||||
return {};
|
||||
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
||||
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))
|
||||
{
|
||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||
@@ -166,6 +182,10 @@ class Transaction
|
||||
DWORD now = GetClipboardSequenceNumber();
|
||||
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;
|
||||
changed = now;
|
||||
stable = GetTickCount64();
|
||||
@@ -183,7 +203,8 @@ class Transaction
|
||||
return {};
|
||||
if (!chord(L'C', MOD_CONTROL))
|
||||
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;
|
||||
retryAt = GetTickCount64() + 250;
|
||||
}
|
||||
@@ -191,6 +212,10 @@ class Transaction
|
||||
}
|
||||
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)" :
|
||||
L"Copy failed: context changed");
|
||||
return {};
|
||||
@@ -201,6 +226,9 @@ class Transaction
|
||||
expected = changed;
|
||||
auto clip = readClipboard(app.window);
|
||||
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;
|
||||
check();
|
||||
for (auto &f : clip.formats)
|
||||
@@ -233,6 +261,27 @@ class Transaction
|
||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||
{
|
||||
// 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() &&
|
||||
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
||||
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
||||
@@ -241,6 +290,7 @@ class Transaction
|
||||
return {};
|
||||
Sleep(25);
|
||||
auto text = copy();
|
||||
diagnosticEvent(L"Selection word-boundary match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||
if (valid() && matches(text))
|
||||
{
|
||||
diagnosticEvent(L"Selection: verified word boundary");
|
||||
@@ -251,6 +301,7 @@ class Transaction
|
||||
Sleep(25);
|
||||
}
|
||||
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.
|
||||
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||
if (valid() && !matches(text) && !text.empty() &&
|
||||
@@ -263,10 +314,11 @@ class Transaction
|
||||
{
|
||||
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||
return {};
|
||||
diagnosticEvent(L"Selection recovery Shift+Left sent=" + std::to_wstring(i + 1) + L" total=" + std::to_wstring(missing));
|
||||
Sleep(25);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -286,6 +338,32 @@ class Transaction
|
||||
Sleep(300);
|
||||
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
|
||||
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();
|
||||
last.reset();
|
||||
++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.
|
||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||
@@ -560,6 +639,7 @@ void App::reloadLayouts()
|
||||
run.reset();
|
||||
last.reset();
|
||||
++inputVersion;
|
||||
if (busy) diagnosticEvent(L"Input context changed: layouts reloaded version=" + std::to_wstring(inputVersion.load()));
|
||||
}
|
||||
registerHotkeys(settings);
|
||||
}
|
||||
@@ -589,6 +669,7 @@ void App::resetInput()
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
++inputVersion;
|
||||
if (busy) diagnosticEvent(L"Input context changed: reset version=" + std::to_wstring(inputVersion.load()));
|
||||
run.reset();
|
||||
last.reset();
|
||||
}
|
||||
@@ -606,6 +687,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
if (isHotkey(vk, mods))
|
||||
return;
|
||||
++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();
|
||||
HWND fg = GetForegroundWindow();
|
||||
HKL layout = windowLayout(fg);
|
||||
@@ -839,7 +923,10 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||
// 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 (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
|
||||
current->resetInput();
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
@@ -1010,9 +1097,10 @@ void App::transform(int command, int snippet)
|
||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
||||
{"expected", bounded(result)}, {"actual", bounded(actual)},
|
||||
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
|
||||
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
|
||||
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
|
||||
{"selectionExpected", bounded(expected)},
|
||||
{"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));
|
||||
}
|
||||
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))
|
||||
{
|
||||
reason = L"undo-verification-mismatch";
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -1177,6 +1267,8 @@ void App::transform(int command, int snippet)
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
if (hint < 0)
|
||||
{
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
@@ -1194,7 +1286,14 @@ void App::transform(int command, int snippet)
|
||||
{ return newlines(candidate) == newlines(previous->result); });
|
||||
if (newlines(text) != newlines(previous->result))
|
||||
{
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
reason = L"previous-result-verification-mismatch";
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
last.reset();
|
||||
run.reset();
|
||||
}
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -1264,11 +1363,30 @@ void App::transform(int command, int snippet)
|
||||
if (tx.valid())
|
||||
{
|
||||
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;
|
||||
if (observed && tx.valid())
|
||||
{
|
||||
actual = *observed;
|
||||
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";
|
||||
}
|
||||
@@ -1453,28 +1571,28 @@ void App::recognizeEntry(std::wstring id)
|
||||
void App::trayMenu()
|
||||
{
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
|
||||
SetMenuDefaultItem(menu, 11, FALSE);
|
||||
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"Keyboard shortcuts…");
|
||||
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen awake");
|
||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
||||
HMENU pause = CreatePopupMenu();
|
||||
AppendMenuW(pause, MF_STRING, 20, L"Resume");
|
||||
AppendMenuW(pause, MF_STRING, 21, L"10 minutes");
|
||||
AppendMenuW(pause, MF_STRING, 22, L"1 hour");
|
||||
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
||||
AppendMenuW(menu, MF_STRING, 18, L"View log…");
|
||||
AppendMenuW(pause, MF_STRING, 20, L"&Resume");
|
||||
AppendMenuW(pause, MF_STRING, 21, L"&10 minutes");
|
||||
AppendMenuW(pause, MF_STRING, 22, L"1 &hour");
|
||||
AppendMenuW(pause, MF_STRING, 23, L"&Until restart");
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"&Pause clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 15, L"&Open data folder");
|
||||
AppendMenuW(menu, MF_STRING, 18, L"View &log…");
|
||||
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
||||
updateRunning ? L"Update (checking / preparing…)" :
|
||||
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
|
||||
updateRunning ? L"&Update (checking / preparing…)" :
|
||||
availableUpdate.empty() ? L"&Update" : L"&Update (new version available)");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
||||
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
||||
AppendMenuW(menu, MF_STRING, 16, L"&About KSwitcher");
|
||||
AppendMenuW(menu, MF_STRING, 17, L"E&xit");
|
||||
POINT pt;
|
||||
GetCursorPos(&pt);
|
||||
pasteTarget = GetForegroundWindow();
|
||||
@@ -1837,6 +1955,9 @@ int App::execute()
|
||||
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
|
||||
continue;
|
||||
}
|
||||
// Preserve history-specific Enter/Shift+Enter and Ctrl+digit handling.
|
||||
if (historyWindow && msg.message == WM_SYSCHAR && IsDialogMessageW(historyWindow, &msg))
|
||||
continue;
|
||||
if (settingsWindow && IsDialogMessageW(settingsWindow, &msg))
|
||||
continue;
|
||||
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
||||
|
||||
+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{};
|
||||
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
||||
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 std::exception &e) { result.status = wide(e.what()); }
|
||||
@@ -65,7 +68,7 @@ void App::pollSync()
|
||||
nextSync = GetTickCount64() + 2000;
|
||||
return;
|
||||
}
|
||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts);
|
||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
||||
if (encodeSettings(next) != syncSnapshot)
|
||||
{
|
||||
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
||||
|
||||
+66
-1
@@ -164,6 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||
if (!dictionaries.contains(sourceLanguage))
|
||||
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 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 = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||
}
|
||||
return candidate;
|
||||
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||||
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,
|
||||
const Layout &physical, const Settings &settings)
|
||||
|
||||
@@ -54,6 +54,8 @@ struct Settings
|
||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
bool autoReplace = false, autoTypos = false;
|
||||
std::map<std::wstring, std::wstring> autoReplacements;
|
||||
bool writeLog = true;
|
||||
bool screenshotAutoOpenEditor = 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"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||
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: " +
|
||||
(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: " +
|
||||
|
||||
+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
|
||||
// character. Give each change time to settle before sending the next one.
|
||||
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))
|
||||
{
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
||||
return false;
|
||||
if (i < 32 || i + 1 == count)
|
||||
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
|
||||
Sleep(25);
|
||||
}
|
||||
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_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 valid();
|
||||
@@ -574,7 +581,7 @@ DiagnosticBuffer diagnosticBuffer;
|
||||
}
|
||||
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.emplace_back(now, message);
|
||||
if (!problem)
|
||||
|
||||
@@ -108,6 +108,19 @@ void validateSettings(const Settings &s)
|
||||
throw std::runtime_error("Unsupported auto-correction language");
|
||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||
throw std::runtime_error("Too many auto-correction exceptions");
|
||||
if (s.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)
|
||||
{
|
||||
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.awake = j.value("preventScreenLock", 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.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||
@@ -268,6 +290,11 @@ Bytes encodeSettings(const Settings &s)
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
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["autoCorrectMinLength"] = s.autoMinLength;
|
||||
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)},
|
||||
{"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)
|
||||
{
|
||||
@@ -154,6 +154,7 @@ SyncConfig loadSyncConfig(const std::filesystem::path &root)
|
||||
if (!syncVisible(root)) return c;
|
||||
auto j = parse(readFile(root / L"sync.json", 16384));
|
||||
c.enabled = j.value("enabled", false);
|
||||
c.keepLocalLayouts = j.value("keepLocalLayouts", false);
|
||||
c.provider = wide(j.value("provider", "openbao"));
|
||||
c.server = wide(j.value("server", ""));
|
||||
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);
|
||||
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));
|
||||
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));
|
||||
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||
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 (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;
|
||||
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
||||
std::set<std::string> missing;
|
||||
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"))
|
||||
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);
|
||||
return decodeSettings(bytes(original), layouts);
|
||||
}
|
||||
#include "sync_merge.inl"
|
||||
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 (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."};
|
||||
}
|
||||
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;
|
||||
if (action == SyncAction::Automatic) merged = mergeSyncProfile(base, local, remote.settings, choices);
|
||||
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
|
||||
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, {}};
|
||||
}
|
||||
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";
|
||||
if (!std::filesystem::exists(path)) return {};
|
||||
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)
|
||||
{
|
||||
|
||||
+4
-2
@@ -7,6 +7,7 @@ namespace ks
|
||||
struct SyncConfig
|
||||
{
|
||||
bool enabled{};
|
||||
bool keepLocalLayouts{};
|
||||
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
||||
path = L"personal/settings", authMount = L"userpass", username;
|
||||
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 = {});
|
||||
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
|
||||
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 };
|
||||
struct SyncResult
|
||||
{
|
||||
@@ -64,7 +65,8 @@ struct SyncResult
|
||||
std::vector<SyncConflict> conflicts;
|
||||
};
|
||||
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 &);
|
||||
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
||||
}
|
||||
|
||||
+12
-2
@@ -4,6 +4,9 @@ namespace
|
||||
{
|
||||
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();
|
||||
for (auto &sn : j.at("snippets"))
|
||||
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)
|
||||
{
|
||||
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();
|
||||
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"},
|
||||
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
||||
{"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"},
|
||||
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
||||
{"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)
|
||||
{
|
||||
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.");
|
||||
auto b = base.empty() ? l : treeProfile(parse(base));
|
||||
// With no common history, union independent entries; differing scalar values
|
||||
// remain on their respective devices until the user resolves the conflict.
|
||||
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
|
||||
SyncMerge result;
|
||||
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
||||
std::set<std::string> collisions;
|
||||
|
||||
+101
-61
@@ -83,7 +83,7 @@ void arrangeWindow(HWND w)
|
||||
{
|
||||
int id = GetDlgCtrlID(child);
|
||||
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.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
||||
if (child == layout->stretch)
|
||||
@@ -493,7 +493,7 @@ bool selectedImage(HWND w, HistoryView *v)
|
||||
}
|
||||
void updateHistoryActions(HWND w, HistoryView *v)
|
||||
{
|
||||
SetDlgItemTextW(w,114,selectedImage(w,v)?L"Edit image":L"Open item");
|
||||
SetDlgItemTextW(w,114,selectedImage(w,v)?L"&Edit image":L"&Open item");
|
||||
}
|
||||
std::wstring selected(HWND w, HistoryView *v)
|
||||
{
|
||||
@@ -730,6 +730,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||
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);
|
||||
if (id == 110 || id == 111)
|
||||
{
|
||||
@@ -854,12 +861,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
p={row.left,row.top}; ClientToScreen(list,&p);
|
||||
}
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"Edit image in screenshot editor":L"Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
|
||||
AppendMenuW(menu, MF_STRING, 110, L"&Paste");
|
||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain &text");
|
||||
AppendMenuW(menu, MF_STRING, 112, L"P&in / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"&Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"&Edit image in screenshot editor":L"&Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"&Copy text from image (OCR)");
|
||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
||||
DestroyMenu(menu);
|
||||
if (cmd)
|
||||
@@ -1112,6 +1119,25 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||
draft.autoPrefixChars = value(w, 408);
|
||||
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)
|
||||
throw std::runtime_error("Select at least two languages");
|
||||
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.autoPrefixChars = draft.autoPrefixChars;
|
||||
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);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -1153,15 +1182,15 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
void showAutoOptions(HWND parent, SettingsView *v)
|
||||
{
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||
560, 680);
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic correction", autoOptionsProc,
|
||||
560, 880);
|
||||
windowLayoutState(w)->autoOptions = true;
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||
checkbox(w, L"English", 401, 18, 48, 150);
|
||||
checkbox(w, L"Ukrainian", 402, 188, 48, 150);
|
||||
checkbox(w, L"Russian", 403, 358, 48, 150);
|
||||
checkbox(w, L"&English", 401, 18, 48, 150);
|
||||
checkbox(w, L"&Ukrainian", 402, 188, 48, 150);
|
||||
checkbox(w, L"&Russian", 403, 358, 48, 150);
|
||||
for (auto &lang : v->draft.autoLanguages)
|
||||
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
||||
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
||||
@@ -1174,7 +1203,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
||||
{
|
||||
int x = 18 + (id - 411) * 178;
|
||||
label(w, id == 411 ? L"English" : id == 412 ? L"Ukrainian" : L"Russian", x, 302, 168);
|
||||
label(w, id == 411 ? L"En&glish" : id == 412 ? L"U&krainian" : L"Ru&ssian", x, 302, 168);
|
||||
edit(w, id, x, 328, 168, 56, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||
auto words = v->draft.autoWords.find(language);
|
||||
if (words != v->draft.autoWords.end()) value(w, id, commaList(words->second));
|
||||
@@ -1192,10 +1221,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
value(w, 408, v->draft.autoPrefixChars);
|
||||
value(w, 409, v->draft.autoSuffixChars);
|
||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
||||
button(w, L"Open dictionary folder", 407, 18, 562, 220);
|
||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
||||
button(w, L"OK", IDOK, 326, 632, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
||||
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
||||
check(w, 414, v->draft.autoReplace);
|
||||
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
|
||||
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{};
|
||||
GetWindowRect(w, &initial);
|
||||
initial.right += scale(w, 200);
|
||||
@@ -1278,23 +1317,23 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
|
||||
label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600);
|
||||
checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
|
||||
checkbox(w, L"&Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
|
||||
check(w, 501, v->draft.useTesseract);
|
||||
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
|
||||
edit(w, 502, 18, 118, 484);
|
||||
value(w, 502, v->draft.tesseractPath);
|
||||
button(w, L"Browse…", 504, 514, 117, 106);
|
||||
button(w, L"&Browse…", 504, 514, 117, 106);
|
||||
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
|
||||
edit(w, 503, 18, 188, 602);
|
||||
value(w, 503, v->draft.tesseractLanguages);
|
||||
button(w, L"Check installation", 505, 18, 228, 180);
|
||||
button(w, L"Check &installation", 505, 18, 228, 180);
|
||||
control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24);
|
||||
button(w, L"Download Tesseract…", 507, 18, 266, 180);
|
||||
button(w, L"&Download Tesseract…", 507, 18, 266, 180);
|
||||
label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408);
|
||||
label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602);
|
||||
label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602);
|
||||
button(w, L"OK", IDOK, 406, 390, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
|
||||
button(w, L"&OK", IDOK, 406, 390, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 520, 390, 100);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
@@ -1606,18 +1645,19 @@ void showHistory(App &app, bool visible)
|
||||
app.historyWindow = w;
|
||||
windowLayoutState(w)->history = true;
|
||||
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
||||
label(w, L"Search clipboard history", 16, 12, 500);
|
||||
label(w, L"&Search clipboard history", 16, 12, 500);
|
||||
edit(w, 101, 16, 38, 728);
|
||||
auto list = control(w, L"LISTBOX", L"",
|
||||
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
||||
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
||||
102, 16, 76, 728, 360);
|
||||
SendMessageW(list, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||
button(w, L"Paste", 110, 16, 452, 90);
|
||||
button(w, L"Plain text", 111, 114, 452, 100);
|
||||
button(w, L"Pin / unpin", 112, 222, 452, 110);
|
||||
button(w, L"Delete", 113, 340, 452, 90);
|
||||
button(w, L"Open item", 114, 438, 452, 100);
|
||||
button(w, L"&Paste", 110, 16, 452, 90);
|
||||
button(w, L"Plain &text", 111, 114, 452, 100);
|
||||
button(w, L"P&in / unpin", 112, 222, 452, 110);
|
||||
button(w, L"&Delete", 113, 340, 452, 90);
|
||||
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(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
||||
}
|
||||
@@ -1656,13 +1696,13 @@ void showSettings(App &app, bool visible)
|
||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660, 0, false);
|
||||
app.settingsWindow = w;
|
||||
label(w, L"Text conversion", 18, 16);
|
||||
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 390);
|
||||
checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 390);
|
||||
checkbox(w, L"Record clipboard history", 203, 18, 106);
|
||||
checkbox(w, L"Start with Windows", 204, 18, 133);
|
||||
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
|
||||
checkbox(w, L"Keep screen awake", 205, 18, 184, 210);
|
||||
checkbox(w, L"Write log", 208, 240, 184, 160);
|
||||
checkbox(w, L"Con&vert recently typed text when nothing is selected", 201, 18, 43, 390);
|
||||
checkbox(w, L"Switch Windows input &layout after conversion", 202, 18, 70, 390);
|
||||
checkbox(w, L"Record clip&board history", 203, 18, 106);
|
||||
checkbox(w, L"Start with &Windows", 204, 18, 133);
|
||||
checkbox(w, L"Start with Windows as ad&ministrator", 206, 18, 160, 395);
|
||||
checkbox(w, L"&Keep screen awake", 205, 18, 184, 210);
|
||||
checkbox(w, L"Write lo&g", 208, 240, 184, 160);
|
||||
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
|
||||
L"Clipboard history", L"Screen capture"};
|
||||
@@ -1671,18 +1711,18 @@ void showSettings(App &app, bool visible)
|
||||
label(w, names[i], 420, 48 + i * 30, 115);
|
||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||
}
|
||||
checkbox(w, L"Open editor after screen capture", 209, 420, 224, 340);
|
||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
||||
checkbox(w, L"Open editor after screen capt&ure", 209, 420, 224, 340);
|
||||
checkbox(w, L"Automatic correction (e&xperimental)", 207, 18, 210, 395);
|
||||
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,
|
||||
18, 262, 470, 172);
|
||||
button(w, L"Move up", 232, 504, 262, 120);
|
||||
button(w, L"Move down", 233, 632, 262, 128);
|
||||
checkbox(w, L"Include in cycle", 230, 504, 300, 240);
|
||||
button(w, L"Move u&p", 232, 504, 262, 120);
|
||||
button(w, L"Move &down", 233, 632, 262, 128);
|
||||
checkbox(w, L"&Include in cycle", 230, 504, 300, 240);
|
||||
label(w, L"Direct layout shortcut", 504, 332, 240);
|
||||
hotkeyEdit(app, w, 231, 504, 356, 256);
|
||||
label(w, L"Backspace / Delete clears a shortcut", 504, 382, 256);
|
||||
button(w, L"Refresh layouts", 253, 504, 408, 150);
|
||||
button(w, L"Re&fresh layouts", 253, 504, 408, 150);
|
||||
label(w, L"History limits", 18, 444);
|
||||
label(w, L"Items", 18, 478, 50);
|
||||
edit(w, 240, 70, 474, 90);
|
||||
@@ -1690,18 +1730,18 @@ void showSettings(App &app, bool visible)
|
||||
edit(w, 241, 260, 474, 90);
|
||||
label(w, L"Per item MiB", 370, 478, 100);
|
||||
edit(w, 242, 475, 474, 80);
|
||||
button(w, L"Clear history…", 254, 580, 474, 180);
|
||||
button(w, L"Clear &history…", 254, 580, 474, 180);
|
||||
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
||||
edit(w, 243, 18, 544, 742);
|
||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
||||
button(w, L"View log…", 256, 278, 575, 220);
|
||||
button(w, L"OCR options…", 257, 518, 575, 242);
|
||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||
button(w, L"Sync...", 258, 448, 606, 90);
|
||||
button(w, L"Save", IDOK, 548, 606, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||
button(w, L"&Auto-correction options…", 255, 18, 575, 240);
|
||||
button(w, L"View l&og…", 256, 278, 575, 220);
|
||||
button(w, L"OCR op&tions…", 257, 518, 575, 242);
|
||||
button(w, L"Impo&rt settings…", 250, 18, 606, 145);
|
||||
button(w, L"&Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"S&nippets…", 252, 328, 606, 110);
|
||||
button(w, L"S&ync...", 258, 448, 606, 90);
|
||||
button(w, L"&Save", IDOK, 548, 606, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 660, 606, 100);
|
||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (visible)
|
||||
{
|
||||
@@ -1721,17 +1761,17 @@ void showSnippets(App &app)
|
||||
label(w, L"Triggers", 16, 16);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
||||
16, 44, 200, 380);
|
||||
button(w, L"Add", 310, 16, 438, 90);
|
||||
button(w, L"Delete", 311, 116, 438, 100);
|
||||
label(w, L"Trigger (for example ;addr)", 236, 16, 470);
|
||||
button(w, L"&Add", 310, 16, 438, 90);
|
||||
button(w, L"&Delete", 311, 116, 438, 100);
|
||||
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
||||
edit(w, 302, 236, 44, 488);
|
||||
label(w, L"Replacement text", 236, 82, 480);
|
||||
label(w, L"&Replacement text", 236, 82, 480);
|
||||
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
||||
button(w, L"Insert Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"Save", IDOK, 510, 478, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||
button(w, L"Insert cursor &marker", 312, 236, 430, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"&Save", IDOK, 510, 478, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 624, 478, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
|
||||
+9
-9
@@ -309,18 +309,18 @@ void showLogs(App &app)
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||
button(w, L"Refresh", LogRefresh, 310, 14, 100);
|
||||
button(w, L"Copy selected", LogCopy, 424, 14, 140);
|
||||
button(w, L"Clear log", LogClear, 578, 14, 100);
|
||||
button(w, L"&Refresh", LogRefresh, 310, 14, 100);
|
||||
button(w, L"&Copy selected", LogCopy, 424, 14, 140);
|
||||
button(w, L"Clear &log", LogClear, 578, 14, 100);
|
||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
|
||||
label(w, L"Program", 16, 78, 170);
|
||||
label(w, L"&Program", 16, 78, 170);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
|
||||
label(w, L"Command", 206, 78, 200);
|
||||
label(w, L"Co&mmand", 206, 78, 200);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
|
||||
label(w, L"Result", 426, 78, 300);
|
||||
label(w, L"Res&ult", 426, 78, 300);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
|
||||
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
|
||||
button(w, L"Reset filters", LogReset, 938, 98, 146);
|
||||
checkbox(w, L"&Only problems", LogProblems, 752, 102, 166);
|
||||
button(w, L"Reset &filters", LogReset, 938, 98, 146);
|
||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&common);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
@@ -344,7 +344,7 @@ void showLogs(App &app)
|
||||
w, L"STATIC",
|
||||
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
|
||||
16, 712, 860, 22);
|
||||
button(w, L"Close", IDCANCEL, 974, 708, 110);
|
||||
button(w, L"Clos&e", IDCANCEL, 974, 708, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->details = details;
|
||||
|
||||
+59
-18
@@ -1,9 +1,29 @@
|
||||
#include "ui_sync_conflicts.inl"
|
||||
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 c;
|
||||
c.enabled = checked(w, 601);
|
||||
c.keepLocalLayouts = checked(w, 613);
|
||||
c.server = value(w, 602);
|
||||
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
||||
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)
|
||||
{
|
||||
value(w, 608, v->app->syncCommand());
|
||||
updateSyncControls(w, *v->app);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
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 == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||
if (id == 609)
|
||||
@@ -35,18 +62,28 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
forgetSyncPassword(c);
|
||||
check(w, 601, false);
|
||||
value(w, 604, L"");
|
||||
updateSyncControls(w, *v->app);
|
||||
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
||||
KillTimer(w, 1);
|
||||
return 0;
|
||||
}
|
||||
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 ?
|
||||
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"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
||||
auto c = syncFields(w);
|
||||
if (id != IDOK && !c.enabled) throw std::runtime_error("Enable synchronization first.");
|
||||
if (c.enabled) validateSyncConfig(c);
|
||||
auto password = value(w, 604);
|
||||
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"");
|
||||
saveSyncConfig(v->app->data, c);
|
||||
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
|
||||
updateSyncControls(w, *v->app);
|
||||
SetTimer(w, 1, 1000, nullptr);
|
||||
return 0;
|
||||
}
|
||||
@@ -80,28 +118,31 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
void showSyncOptions(HWND parent, App &app)
|
||||
{
|
||||
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_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);
|
||||
check(w, 601, c.enabled);
|
||||
label(w, L"Server (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
|
||||
label(w, L"Username", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
|
||||
label(w, L"Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
|
||||
label(w, L"Se&rver (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
|
||||
label(w, L"User&name", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
|
||||
label(w, L"&Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
|
||||
label(w, L"Leave password blank to use the saved Windows credential.", 190, 166, 450);
|
||||
label(w, L"KV v2 mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
|
||||
label(w, L"Profile path", 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"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
|
||||
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"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());
|
||||
button(w, L"Save / Sign in / Sync", IDOK, 18, 452, 200);
|
||||
button(w, L"Download remote", 610, 230, 452, 195);
|
||||
button(w, L"Upload local", 611, 437, 452, 203);
|
||||
button(w, L"Resolve individual conflicts...", 612, 18, 498, 300);
|
||||
label(w, L"Background check: every 10 minutes", 330, 502, 310);
|
||||
button(w, L"Disconnect / forget password", 609, 18, 550, 290);
|
||||
button(w, L"Close", IDCANCEL, 540, 550, 100);
|
||||
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
|
||||
check(w, 613, c.keepLocalLayouts);
|
||||
button(w, L"&Save / Sign in / Sync", IDOK, 18, 484, 200);
|
||||
button(w, L"&Download remote", 610, 230, 484, 195);
|
||||
button(w, L"&Upload local", 611, 437, 484, 203);
|
||||
button(w, L"Resolve &individual conflicts...", 612, 18, 530, 300);
|
||||
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);
|
||||
SetTimer(w, 1, 1000, nullptr);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
|
||||
@@ -70,11 +70,11 @@ void showSyncConflicts(HWND parent, App &app)
|
||||
label(w, L"This PC", 18, 190, 360); label(w, L"Server", 410, 190, 368);
|
||||
edit(w, 621, 18, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
edit(w, 622, 410, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
button(w, L"Use this PC for selected setting", 623, 18, 412, 368);
|
||||
button(w, L"Use server for selected setting", 624, 410, 412, 368);
|
||||
button(w, L"Use this &PC for selected setting", 623, 18, 412, 368);
|
||||
button(w, L"Use &server for selected setting", 624, 410, 412, 368);
|
||||
label(w, L"Unselected conflicts stay pending. Changed values will be checked again before saving.", 18, 454, 760);
|
||||
button(w, L"Apply choices", IDOK, 526, 488, 140);
|
||||
button(w, L"Cancel", IDCANCEL, 678, 488, 100);
|
||||
button(w, L"&Apply choices", IDOK, 526, 488, 140);
|
||||
button(w, L"&Cancel", IDCANCEL, 678, 488, 100);
|
||||
SendDlgItemMessageW(w, 620, LB_SETCURSEL, 0, 0); showConflictValues(w, v);
|
||||
EnableWindow(parent, FALSE); showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.26";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.30";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+66
-5
@@ -599,6 +599,9 @@ int main(int argc, char **argv) {
|
||||
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
||||
app.resetInput();
|
||||
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");
|
||||
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)})
|
||||
@@ -627,6 +630,24 @@ int main(int argc, char **argv) {
|
||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||
ks::showSettings(app,false);
|
||||
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
|
||||
// Mnemonics only target visible controls; show without taking foreground focus.
|
||||
ShowWindow(app.settingsWindow,SW_SHOWNOACTIVATE);
|
||||
auto previousActive=SetActiveWindow(app.settingsWindow);
|
||||
auto awakeBefore=SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0);
|
||||
MSG accessKey{}; accessKey.hwnd=GetDlgItem(app.settingsWindow,201);
|
||||
accessKey.message=WM_SYSCHAR; accessKey.wParam=L'k'; accessKey.lParam=1LL<<29;
|
||||
require(IsDialogMessageW(app.settingsWindow,&accessKey),"Alt+K is handled as a dialog access key");
|
||||
MSG dispatched{};
|
||||
while(PeekMessageW(&dispatched,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&dispatched); DispatchMessageW(&dispatched); }
|
||||
require(SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0)!=awakeBefore,
|
||||
"Alt+K toggles Keep screen awake");
|
||||
IsDialogMessageW(app.settingsWindow,&accessKey);
|
||||
while(PeekMessageW(&dispatched,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&dispatched); DispatchMessageW(&dispatched); }
|
||||
require(SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0)==awakeBefore,
|
||||
"repeated access key restores checkbox state");
|
||||
ShowWindow(app.settingsWindow,SW_HIDE);
|
||||
SetActiveWindow(previousActive);
|
||||
|
||||
DestroyWindow(app.settingsWindow);
|
||||
ks::saveSyncConfig(app.data,ks::SyncConfig{});
|
||||
ks::showSettings(app,false);
|
||||
@@ -638,6 +659,23 @@ int main(int argc, char **argv) {
|
||||
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
|
||||
"sync password is masked");
|
||||
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);
|
||||
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
||||
@@ -660,7 +698,7 @@ int main(int argc, char **argv) {
|
||||
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
|
||||
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
|
||||
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{};
|
||||
GetWindowRect(GetDlgItem(syncWindow,id),&control);
|
||||
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
|
||||
@@ -668,8 +706,12 @@ int main(int argc, char **argv) {
|
||||
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||
"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);
|
||||
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);
|
||||
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||
@@ -686,21 +728,31 @@ int main(int argc, char **argv) {
|
||||
GetWindowRect(autoOptions,&original);
|
||||
// Use a client size above the scrolling baseline at every DPI.
|
||||
UINT dpi=GetDpiForWindow(autoOptions);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(1100,dpi,96),
|
||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||
RECT clientBefore{}; GetClientRect(autoOptions,&clientBefore);
|
||||
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
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) {
|
||||
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
||||
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
||||
int extraX=(extraAfterX*(i+1)/3-extraAfterX*i/3)-
|
||||
(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");
|
||||
}
|
||||
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");
|
||||
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||
after[2].bottom<buttonAfter.top,
|
||||
@@ -717,6 +769,9 @@ int main(int argc, char **argv) {
|
||||
"personal dictionary field fits options window");
|
||||
}
|
||||
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);
|
||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||
@@ -726,6 +781,12 @@ int main(int argc, char **argv) {
|
||||
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
||||
require(std::wstring(personalWords)==L"релізик, словечко",
|
||||
"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);
|
||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||
ks::showHistory(app,false);
|
||||
|
||||
+95
-1
@@ -9,6 +9,7 @@ namespace ks
|
||||
{
|
||||
struct InputRegressionAccess
|
||||
{
|
||||
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||
static bool deferring(App &app)
|
||||
{
|
||||
std::lock_guard lock(app.stateMutex);
|
||||
@@ -23,6 +24,7 @@ HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
bool captured = false, failed = false;
|
||||
bool conversionOnly = false;
|
||||
bool typosOnly = false;
|
||||
int pasteBehavior = 0;
|
||||
int enterPresses = 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 (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
|
||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||
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",
|
||||
"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::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
|
||||
pasteBehavior = 2;
|
||||
prepare(L"ghbdtn", true);
|
||||
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()));
|
||||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||
"unexpected editor content records readback mismatch");
|
||||
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
|
||||
pasteBehavior = 0;
|
||||
prepare(L"привет", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
@@ -427,8 +432,41 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case 47:
|
||||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||
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();
|
||||
break;
|
||||
}
|
||||
case 30:
|
||||
case 32:
|
||||
case 34:
|
||||
@@ -597,6 +635,61 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case 22:
|
||||
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;
|
||||
break;
|
||||
}
|
||||
@@ -621,6 +714,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||||
typosOnly = argc > 1 && std::string(argv[1]) == "--typos-only";
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
auto root = std::filesystem::temp_directory_path() /
|
||||
(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);
|
||||
deadline = GetTickCount64() + 30000;
|
||||
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;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||
stage = 30;
|
||||
|
||||
+79
-1
@@ -54,6 +54,10 @@ int main()
|
||||
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
||||
saveSyncConfig(root, config);
|
||||
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));
|
||||
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
||||
Settings local;
|
||||
@@ -84,9 +88,49 @@ int main()
|
||||
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
||||
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
||||
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");
|
||||
{
|
||||
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");
|
||||
{
|
||||
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;
|
||||
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||
@@ -173,6 +217,40 @@ int main()
|
||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
||||
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
||||
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,
|
||||
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
||||
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,
|
||||
"context expires after thirty seconds");
|
||||
diagnostics.record(L"oldest-context", false, 30600);
|
||||
for (int i = 0; i < 40; ++i)
|
||||
diagnostics.record(L"bounded-context", false, 30600 + i);
|
||||
for (int i = 0; i < 128; ++i)
|
||||
diagnostics.record(L"bounded-context", false, 30600);
|
||||
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
||||
"context buffer has a fixed size");
|
||||
diagnostics.record(L"disabled-context", false, 30800);
|
||||
@@ -217,6 +217,13 @@ int main(int argc, char **argv)
|
||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||
autoSettings.cycle = {L"en", L"uk"};
|
||||
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);
|
||||
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
||||
"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);
|
||||
require(correction && correction->text == L"hello ",
|
||||
"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::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.elevatedAutostart = true;
|
||||
settings.autoCorrect = true;
|
||||
settings.autoReplace = settings.autoTypos = true;
|
||||
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
||||
settings.autoMaxRank = 10000;
|
||||
settings.autoMinLength = 5;
|
||||
settings.autoPrefixChars = L"(#";
|
||||
@@ -554,6 +602,14 @@ int main(int argc, char **argv)
|
||||
ks::saveSettings(temp / L"settings.json", settings);
|
||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||
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;
|
||||
invalidWords.autoWords[L"uk"] = {L"hello"};
|
||||
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
||||
|
||||
Reference in New Issue
Block a user