Compare commits

..

2 Commits

25 changed files with 545 additions and 66 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.8 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.10 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
@@ -9,7 +9,7 @@ add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorre
target_include_directories(kscore PUBLIC src PRIVATE vendor) target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP) target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore) target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
@@ -18,9 +18,9 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore) target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp) add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore) target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore) target_link_libraries(KSwitcherIntegration PRIVATE kscore)
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore) target_link_libraries(KSwitcherInputTests PRIVATE kscore)
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES) target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc) add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
@@ -36,7 +36,7 @@ if(MSVC)
else() else()
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
endif() endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore) target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC) if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>") set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
+17 -3
View File
@@ -178,6 +178,11 @@ Select **Update** in the tray menu to check the stable Releases of
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases). [this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
A background check runs shortly after startup and every 6 hours while KSwitcher is running. A background check runs shortly after startup and every 6 hours while KSwitcher is running.
It reports each available version once per session and never installs automatically. It reports each available version once per session and never installs automatically.
Update availability uses the same Windows toast notification channel as screenshot notifications.
Click the notification or **Review update** to check the latest release and confirm installation;
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
Failed notification submissions are logged and retried after five minutes instead of being marked
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time. Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
Confirm the offered version to download and install it. The updater uses the existing Confirm the offered version to download and install it. The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64 `KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
@@ -338,14 +343,23 @@ after Space, not immediately after a punctuation key. Save Settings to apply cha
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections **Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
only to words with a rank at or below that number (for example, 10000). All dictionary words still only to words with a rank at or below that number (for example, 10000). All dictionary words still
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit. protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
These entries are stored in settings and survive dictionary updates. They protect correct input and
are eligible as correction targets at any frequency limit. The language and target layout must still
be enabled. **Never correct these words** is a separate exception list and takes precedence.
**Open dictionary folder** opens the files beside the running executable. After editing, click OK **Open dictionary folder** opens the files beside the running executable. After editing, click OK
in the options window and Save in Settings to validate and reload the dictionaries without restarting. in the options window and Save in Settings to validate and reload the dictionaries without restarting.
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited loading sorts them and merges duplicates using the lowest rank. Back up your edited
files before replacing dictionaries with a new distribution. files before replacing dictionaries with a new distribution.
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work. dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
pending work. During the replacement itself, key presses and releases are queued and replayed after
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
If the active window changes, queued typing is not sent to another window and a warning is shown.
It only corrects unknown words when exactly one alternative is a dictionary word. It only corrects unknown words when exactly one alternative is a dictionary word.
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation **Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
does not count toward this minimum; the maximum word length is 32 letters. does not count toward this minimum; the maximum word length is 32 letters.
Binary file not shown.
+7 -12
View File
@@ -1,29 +1,24 @@
# Latest Windows release # Latest Windows release
## v0.6.8 — 2026-10-29 ## v0.6.10 — 2026-10-29
## Changes ## Changes
- Recognize clipboard images automatically in a background worker, including older images without OCR. Save recognized text encrypted alongside the original image and search it without rerunning OCR. - Preserve typing during automatic layout correction: queue key presses and releases until replacement, clipboard restoration and layout switching finish. Preserve modifiers and Backspace; collapse temporary selection before replay when a correction is rejected.
- Keep clipboard capture, previews and OCR from delaying history search. Skip unnecessary image-payload reads; ignore superseded search results. - Add My dictionary fields for English, Ukrainian and Russian in Auto-correction options. Enter correct words separated by commas or new lines; save them with settings without editing dictionary files, sorting entries or assigning ranks.
- Add optional Tesseract 5 support in Settings → OCR options, with automatic installation detection, executable selection, combined languages such as ukr+eng+rus, installation validation and a Download Tesseract button linking to the Windows installer page. Windows OCR remains the default; no downloads are automatic. - Accept unsorted dictionary files and duplicate entries, retaining the lowest rank for duplicates. Include Ukrainian `реліз` in the bundled vocabulary so `релыз` can be corrected when Russian and Ukrainian are enabled.
- Refresh cached image text when the OCR engine or languages change. Reuse OCR for Copy text from image; preserve the clipboard during automatic recognition.
- Start the screenshot editor with Pencil / Freehand (F). Pointer remains available with V.
## Validation ## Validation
203 native checks, 445 input/layout checks, screenshot editor UI integration (including the initial pencil cursor), isolated notification registration, and 45 updater checks. Native Windows OCR and optional Tesseract tests verify background recognition, encrypted persistence, unchanged clipboard, mixed Ukrainian/English text, and engine-cache migration. OCR settings were also checked in the running configurator. Native regression checks cover personal words, settings persistence, language validation, dictionary ordering and duplicate ranks. Input/UI checks cover personal-word entry and normalization. Native editor integration covers typing with Shift and Backspace during successful and rejected corrections. Release checks also cover screenshot UI, isolated notification registration and updater validation.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. **Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. Existing customized dictionary files may be preserved by the updater; use My dictionary to add missing words without replacing those files.
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, clipboard history, Tesseract installation or language models. 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, clipboard history, Tesseract installation or language models.
Documentation: [English](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard) · [Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard-uk).
Build and package: Build and package:
```powershell ```powershell
.\build.ps1 -Test -Ocr .\build.ps1 -Stage -Test -Integration
.\build\KSwitcherOcrTests.exe --tesseract
.\package.ps1 -PublishRelease .\package.ps1 -PublishRelease
``` ```
+1 -1
View File
@@ -1 +1 @@
31c2c9923ff0f6d71eb421bcea890409cedf7a48999ebc513d0adedf5a2c9839 KSwitcher-win-x64.zip 36966eb0b2b0af47c245aec6337a16703e0fbdca4d3eda7a8f25fa14a20f347e KSwitcher-win-x64.zip
+4 -4
View File
@@ -27,7 +27,7 @@ try {
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o & windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' } if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' } if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe' $configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
@@ -43,7 +43,7 @@ try {
if ($LASTEXITCODE) { throw 'Test build failed' } if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe & .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' } if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' } if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe & .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' } if ($LASTEXITCODE) { throw 'Input tests failed' }
@@ -63,13 +63,13 @@ try {
if ($LASTEXITCODE) { throw 'Updater tests failed' } if ($LASTEXITCODE) { throw 'Updater tests failed' }
} }
if ($Integration) { if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' } if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe & .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' } if ($LASTEXITCODE) { throw 'Integration tests failed' }
} }
if ($Ocr) { if ($Ocr) {
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe & $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR test build failed' } if ($LASTEXITCODE) { throw 'OCR test build failed' }
& .\build\KSwitcherOcrTests.exe & .\build\KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR tests failed' } if ($LASTEXITCODE) { throw 'OCR tests failed' }
+12 -2
View File
@@ -17,9 +17,19 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
hashes. The lists contain word forms from subtitles, not a complete spelling hashes. The lists contain word forms from subtitles, not a complete spelling
or grammar dictionary. Recognition can miss names, jargon and uncommon forms. or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
File format: UTF-8 word, TAB, positive original frequency rank, newline. Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
after the upstream 50k list, rather than claiming a measured corpus frequency.
They are included with the default unlimited rank setting and recorded in sources.json.
File format: UTF-8 word, TAB, positive frequency rank (or supplemental rank), newline.
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0 All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
(entire dictionary); a positive limit only permits correction candidates at or below that rank. (entire dictionary); a positive limit only permits correction candidates at or below that rank.
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator. Prefer Settings > Auto-correction options > My dictionary for personal words.
Enter correct spellings in the matching language field, separated by commas or new lines.
These words are stored in settings, survive dictionary updates, and need no ranks or sorting.
Click OK, then Save in Settings to apply them.
For advanced file edits, personal words may use rank 1. Keep entries lowercase with a Tab separator.
Words may be appended: loading sorts entries and merges duplicates using the lowest rank.
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it. Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
Back up edited files before replacing dictionaries with a new distribution. Back up edited files before replacing dictionaries with a new distribution.
+6 -3
View File
@@ -9,9 +9,12 @@
"uk": { "uk": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt", "source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a", "sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
"words": 42549, "words": 42550,
"bytes": 898210, "bytes": 898227,
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802" "sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
"supplementalWords": {
"\u0440\u0435\u043b\u0456\u0437": 50001
}
}, },
"ru": { "ru": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt", "source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
+1
View File
@@ -31207,6 +31207,7 @@
релігію 26138 релігію 26138
релігія 22202 релігія 22202
релігії 23427 релігії 23427
реліз 50001
рембо 28603 рембо 28603
ремень 23872 ремень 23872
ремені 28385 ремені 28385
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,8,0 FILEVERSION 0,6,10,0
PRODUCTVERSION 0,6,8,0 PRODUCTVERSION 0,6,10,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.8" VALUE "FileVersion", "0.6.10"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+101 -9
View File
@@ -233,6 +233,7 @@ class Transaction
} // namespace } // namespace
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test) App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
{ {
updateNotifier = notifyUpdateAvailable;
current = this; current = this;
data = dataRoot.empty() ? dataDirectory() : dataRoot; data = dataRoot.empty() ? dataDirectory() : dataRoot;
std::filesystem::create_directories(data); std::filesystem::create_directories(data);
@@ -597,6 +598,27 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input. // down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate) if (ownKeyboardInput(key) || installingUpdate)
return false; return false;
{
std::lock_guard lock(stateMutex);
if (typingTarget && GetForegroundWindow() == typingTarget)
{
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.wVk = static_cast<WORD>(key.vkCode);
input.ki.wScan = static_cast<WORD>(key.scanCode);
input.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) |
((key.flags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0);
if (key.vkCode == VK_PACKET)
{
input.ki.wVk = 0;
input.ki.dwFlags |= KEYEVENTF_UNICODE;
}
// Replay as ordinary input so tracking and shortcuts see it once,
// after the replacement and layout switch have finished.
deferredTyping.push_back(input);
return true;
}
}
DWORD captureProcess{}; DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess); if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess)) if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
@@ -639,6 +661,41 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
return true; return true;
return false; return false;
} }
bool App::beginTypingDeferral(HWND target, uint64_t version)
{
std::lock_guard lock(stateMutex);
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
return false;
typingTarget = target;
return true;
}
void App::replayTyping()
{
std::vector<INPUT> inputs;
HWND target;
{
std::lock_guard lock(stateMutex);
target = typingTarget;
typingTarget = nullptr;
inputs.swap(deferredTyping);
}
busy = false;
if (inputs.empty())
return;
if (GetForegroundWindow() != target)
{
resetInput();
report(L"Typing during correction was not replayed because the active window changed.");
return;
}
// Runs on the hook thread: no physical key can pass between releasing the
// gate and submitting the entire ordered batch to the Windows input queue.
if (SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) != inputs.size())
{
resetInput();
report(L"Windows could not replay all typing buffered during correction.");
}
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp) LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{ {
if (code == HC_ACTION && current && if (code == HC_ACTION && current &&
@@ -674,6 +731,7 @@ void App::dispatch(int command)
return; return;
try try
{ {
if (command == 23) { checkUpdate(true); return; }
if (command == Screenshot) if (command == Screenshot)
{ {
if (!busy) { resetInput(); launchScreenshot(*this); } if (!busy) { resetInput(); launchScreenshot(*this); }
@@ -760,11 +818,16 @@ void App::transform(int command, int snippet)
struct BusyReset struct BusyReset
{ {
std::atomic<bool> &b; std::atomic<bool> &b;
HWND window;
bool deferred{};
~BusyReset() ~BusyReset()
{ {
b = false; if (deferred)
PostMessageW(window, ReplayTypingMessage, 0, 0);
else
b = false;
} }
} reset{busy}; } reset{busy, window};
auto start = GetTickCount64(); auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection"; std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected; std::wstring text, result, expected;
@@ -847,6 +910,21 @@ void App::transform(int command, int snippet)
return; return;
} }
reason = L"clipboard-snapshot"; reason = L"clipboard-snapshot";
if (command == AutoCorrect)
{
if (!beginTypingDeferral(target, version))
return;
reset.deferred = true;
}
// Collapse a temporary selection on every failed auto-correction,
// including clipboard exceptions, before queued typing is replayed.
bool autoSelected = false;
struct SelectionReset
{
bool &selected;
std::function<bool()> valid;
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
} selectionReset{autoSelected, valid};
Transaction tx(*this, target, version); Transaction tx(*this, target, version);
reason = L"selection-unavailable"; reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) + diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
@@ -863,6 +941,7 @@ void App::transform(int command, int snippet)
// conversion, automatic correction must never fall back to a suffix. // conversion, automatic correction must never fall back to a suffix.
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT)) if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
return; return;
autoSelected = true;
Sleep(25); Sleep(25);
text = tx.copy(); text = tx.copy();
// Some editors first select only ") ". Extend by whole-word // Some editors first select only ") ". Extend by whole-word
@@ -879,7 +958,10 @@ void App::transform(int command, int snippet)
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config)) if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
{ {
if (tx.valid()) if (tx.valid())
{
chord(VK_RIGHT); chord(VK_RIGHT);
autoSelected = false;
}
return; return;
} }
to = suggestion->target; to = suggestion->target;
@@ -1016,6 +1098,8 @@ void App::transform(int command, int snippet)
sent = tx.paste(textClip(result)); sent = tx.paste(textClip(result));
else if (automatic && tx.valid()) else if (automatic && tx.valid())
chord(VK_RIGHT); chord(VK_RIGHT);
if (sent || result == text)
autoSelected = false;
reason = result == text ? L"conversion-unchanged" : reason = result == text ? L"conversion-unchanged" :
sent ? L"paste-sent-unverified" : L"paste-cancelled"; sent ? L"paste-sent-unverified" : L"paste-cancelled";
if (result == text && if (result == text &&
@@ -1328,6 +1412,8 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (app->updateWorker.joinable()) app->updateWorker.join(); if (app->updateWorker.joinable()) app->updateWorker.join();
app->updateRunning = false; app->updateRunning = false;
app->installingUpdate = false; app->installingUpdate = false;
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
(result->error.empty() ? L"" : L"; " + result->error));
if (result->state == L"prepared") if (result->state == L"prepared")
{ {
// The helper has verified and staged the package; it waits for process exit. // The helper has verified and staged the package; it waits for process exit.
@@ -1345,13 +1431,16 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
} }
else if (app->notifiedUpdate != result->version) else if (app->notifiedUpdate != result->version)
{ {
app->notifiedUpdate = result->version; if (app->updateNotifier(result->version))
NOTIFYICONDATAW icon{sizeof(icon)}; {
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO; app->notifiedUpdate = result->version;
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available"); log(L"Update notification submitted to Windows: " + result->version);
auto text = result->version + L" is available. Select Update in the tray menu to install."; }
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE); else
Shell_NotifyIconW(NIM_MODIFY, &icon); {
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
}
} }
} }
else if (result->state == L"current") else if (result->state == L"current")
@@ -1370,6 +1459,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_HOTKEY: case WM_HOTKEY:
app->dispatch((int)wp); app->dispatch((int)wp);
return 0; return 0;
case ReplayTypingMessage:
app->replayTyping();
return 0;
case TrayMessage: case TrayMessage:
if (LOWORD(lp) == WM_CONTEXTMENU) if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu(); app->trayMenu();
+6
View File
@@ -16,6 +16,7 @@ inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, Histo
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6, inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
Screenshot = 7, Direct = 100; Screenshot = 7, Direct = 100;
inline constexpr UINT AutoCorrectMessage = WM_APP + 6; inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5; inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform struct LastTransform
{ {
@@ -95,6 +96,7 @@ class App
std::thread updateWorker; std::thread updateWorker;
bool updateRunning{}, installingUpdate{}; bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate, notifiedUpdate; std::wstring availableUpdate, notifiedUpdate;
bool (*updateNotifier)(const std::wstring &){};
bool stop{}; bool stop{};
UINT taskbarCreated{}; UINT taskbarCreated{};
uint64_t pendingAutoVersion{}; uint64_t pendingAutoVersion{};
@@ -104,6 +106,10 @@ class App
int previousAttemptCommand{}, repeatAttempts{}; int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings; std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook; HotkeyHook hotkeyHook;
HWND typingTarget{};
std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t);
void replayTyping();
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM); static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM); static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM); static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
+21 -5
View File
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
} }
if (offsets.empty()) if (offsets.empty())
throw std::runtime_error("Empty auto-correction dictionary"); throw std::runtime_error("Empty auto-correction dictionary");
for (size_t i = 1; i < offsets.size(); ++i) auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
if (word(i - 1) >= word(i)) auto frequency = [&](uint32_t offset)
throw std::runtime_error("Dictionary must be uniquely sorted"); {
int rank{};
auto start = offset + entry(offset).size() + 1;
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
return rank;
};
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
{ return entry(a) == entry(b); }), offsets.end());
offsets.shrink_to_fit(); offsets.shrink_to_fit();
bytes.shrink_to_fit(); bytes.shrink_to_fit();
} }
@@ -151,9 +160,16 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto sourceLanguage = layoutLanguage(layouts[source]); auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage)) if (!dictionaries.contains(sourceLanguage))
return {}; return {};
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
{
auto found = settings.autoWords.find(language);
return found != settings.autoWords.end() &&
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
{ return lower(known) == lower(candidate); });
};
// Valid words from either selected language are never changed. // Valid words from either selected language are never changed.
for (auto &[language, d] : dictionaries) for (auto &[language, d] : dictionaries)
if (d.rank(word)) if (d.rank(word) || personal(language, word))
return {}; return {};
std::optional<AutoSuggestion> candidate; std::optional<AutoSuggestion> candidate;
int matches = 0; int matches = 0;
@@ -168,7 +184,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto converted = convert(word, layouts[source], layouts[i], true); auto converted = convert(word, layouts[source], layouts[i], true);
if (converted == word || !safeAutoWord(converted, settings.autoMinLength)) if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
continue; continue;
int rank = dictionary->second.rank(converted); int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
if (!rank) if (!rank)
continue; continue;
if (++matches > 1) if (++matches > 1)
+2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <map>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -58,6 +59,7 @@ struct Settings
std::wstring autoSuffixChars = L")]}»”’\".,!?:;"; std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
std::vector<std::wstring> autoLanguages{L"en", L"uk"}; std::vector<std::wstring> autoLanguages{L"en", L"uk"};
std::vector<std::wstring> autoNever; std::vector<std::wstring> autoNever;
std::map<std::wstring, std::vector<std::wstring>> autoWords;
std::vector<std::wstring> autoExcluded{ std::vector<std::wstring> autoExcluded{
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh", L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
+3
View File
@@ -45,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
cmd = 21; cmd = 21;
else if (arg == L"--exit") else if (arg == L"--exit")
cmd = 99; cmd = 99;
else if (arg == L"--check-update")
cmd = 23;
LocalFree(argv); LocalFree(argv);
if (existing) if (existing)
{ {
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher"); HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (window) if (window)
{ {
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
COPYDATASTRUCT data{1, sizeof(cmd), &cmd}; COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
DWORD_PTR result{}; DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result); SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
+6 -2
View File
@@ -83,8 +83,12 @@ public:
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; } ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; } ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override { HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG; if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\""; const bool update=std::wstring(args)==L"check-update";
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
auto command=update ? L"\""+path+L"\" --check-update" :
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{}; STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process); auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError()); auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
+39
View File
@@ -181,5 +181,44 @@ bool notifyScreenshot(const Clip &clip)
} }
std::string screenshotNotificationError() { return notificationError; } std::string screenshotNotificationError() { return notificationError; }
std::wstring updateToastXml(const std::wstring &version)
{
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
L" is available. Click to review and install.</text></binding></visual>"
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
}
bool notifyUpdateAvailable(const std::wstring &version)
{
try {
// Reuse the screenshot notification identity, Windows settings and COM activator.
registerScreenshotNotifications();
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
check(manager->CreateToastNotifierWithId(appId.value,&notifier));
Notifications::NotificationSetting setting{};
auto settingsResult=notifier->get_Setting(&setting);
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
if(setting!=Notifications::NotificationSetting_Enabled)
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
check(RoActivateInstance(documentClass.value,&instance));
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
String content(updateToastXml(version)); check(io->LoadXml(content.value));
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
// Repeated submissions replace the existing update card in Notification Center.
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
check(notifier->Show(toast.Get()));
return true;
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
}
} }
+22
View File
@@ -92,6 +92,17 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Unsupported auto-correction language"); throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000) if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions"); throw std::runtime_error("Too many auto-correction exceptions");
for (const auto &[language, words] : s.autoWords)
{
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
language == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" :
language == L"ru" ? L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя" : L"";
if (alphabet.empty() || words.size() > 10000)
throw std::runtime_error("Invalid personal dictionary language or too many words");
for (const auto &word : words)
if (word.empty() || word.size() > 32 || lower(word).find_first_not_of(alphabet) != word.npos)
throw std::runtime_error("Personal words must contain 1-32 letters of their selected language");
}
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 || if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
s.maxImageMiB > 64) s.maxImageMiB > 64)
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image"); throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
@@ -143,6 +154,10 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.autoMinLength = j.value("autoCorrectMinLength", 3); s.autoMinLength = j.value("autoCorrectMinLength", 3);
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars); s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars); s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
if (j.contains("autoCorrectWords"))
for (auto &[language, words] : j.at("autoCorrectWords").items())
for (auto &word : words)
s.autoWords[wide(language)].push_back(lower(wide(word.get<std::string>())));
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages}, for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever}, {"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}}) {"autoCorrectExcludedApps", &s.autoExcluded}})
@@ -229,6 +244,13 @@ Bytes encodeSettings(const Settings &s)
j["autoCorrectMinLength"] = s.autoMinLength; j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars); j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars); j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
j["autoCorrectWords"] = Json::object();
for (const auto &[language, words] : s.autoWords)
{
auto &items = j["autoCorrectWords"][utf8(language)] = Json::array();
for (const auto &word : words)
items.push_back(utf8(lower(word)));
}
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages}, for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever}, {"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}}) {"autoCorrectExcludedApps", &s.autoExcluded}})
+65 -16
View File
@@ -71,17 +71,48 @@ void arrangeWindow(HWND w)
RECT client{}; GetClientRect(w, &client); RECT client{}; GetClientRect(w, &client);
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0; int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0; int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
struct Move { HWND child; RECT rect; };
std::vector<Move> moves;
for (const auto &[child, r] : layout->controls) for (const auto &[child, r] : layout->controls)
{ {
int id = GetDlgCtrlID(child); int id = GetDlgCtrlID(child);
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, RECT next{scale(w, r.left) - layout->x,
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0), scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0), next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0), next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
SWP_NOACTIVATE | SWP_NOZORDER); RECT current{}; GetWindowRect(child, &current);
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(&current), 2);
if (!EqualRect(&current, &next)) moves.push_back({child, next});
}
if (!moves.empty())
{
auto batch = BeginDeferWindowPos((int)moves.size());
for (const auto &move : moves)
{
if (!batch) break;
auto r = move.rect;
batch = DeferWindowPos(batch, move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
if (!batch || !EndDeferWindowPos(batch))
for (const auto &move : moves)
{
auto r = move.rect;
SetWindowPos(move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
} }
layout->arranging = false; layout->arranging = false;
InvalidateRect(w, nullptr, TRUE); }
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
{
SCROLLINFO info{sizeof(info), SIF_RANGE | SIF_PAGE};
GetScrollInfo(w, horizontal ? SB_HORZ : SB_VERT, &info);
int next = std::clamp(requested, info.nMin, std::max(info.nMin, info.nMax - std::max(0, (int)info.nPage - 1)));
int &position = horizontal ? layout->x : layout->y;
if (position == next) return;
position = next;
arrangeWindow(w);
} }
void revealControl(HWND child) void revealControl(HWND child)
{ {
@@ -127,7 +158,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
bool vertical = msg == WM_VSCROLL; bool vertical = msg == WM_VSCROLL;
SCROLLINFO info{sizeof(info), SIF_ALL}; SCROLLINFO info{sizeof(info), SIF_ALL};
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info); GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
int &position = vertical ? layout->y : layout->x; int position = vertical ? layout->y : layout->x;
switch (LOWORD(wp)) switch (LOWORD(wp))
{ {
case SB_LINEUP: position -= scale(w, 32); break; case SB_LINEUP: position -= scale(w, 32); break;
@@ -138,7 +169,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
case SB_BOTTOM: position = info.nMax; break; case SB_BOTTOM: position = info.nMax; break;
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break; case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
} }
arrangeWindow(w); scrollWindow(w, layout, !vertical, position);
return 0; return 0;
} }
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL)) if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
@@ -148,8 +179,9 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
remainder += GET_WHEEL_DELTA_WPARAM(wp); remainder += GET_WHEEL_DELTA_WPARAM(wp);
int steps = remainder / WHEEL_DELTA; int steps = remainder / WHEEL_DELTA;
remainder %= WHEEL_DELTA; remainder %= WHEEL_DELTA;
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1); if (steps)
arrangeWindow(w); scrollWindow(w, layout, horizontal, (horizontal ? layout->x : layout->y) +
steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1));
return 0; return 0;
} }
if (msg == WM_NCDESTROY) if (msg == WM_NCDESTROY)
@@ -276,7 +308,7 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RegisterClassW(&wc); RegisterClassW(&wc);
UINT dpi = GetDpiForSystem(); UINT dpi = GetDpiForSystem();
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)}; RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra; DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX; if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi); AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
POINT cursor{}; POINT cursor{};
@@ -977,6 +1009,15 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (checked(w, 403)) if (checked(w, 403))
draft.autoLanguages.push_back(L"ru"); draft.autoLanguages.push_back(L"ru");
draft.autoNever = parseCommaList(value(w, 405)); draft.autoNever = parseCommaList(value(w, 405));
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
{
auto text = lower(value(w, id));
std::replace(text.begin(), text.end(), L'\n', L',');
auto words = parseCommaList(text);
std::sort(words.begin(), words.end());
words.erase(std::unique(words.begin(), words.end()), words.end());
draft.autoWords[language] = std::move(words);
}
draft.autoExcluded = parseCommaList(value(w, 404)); draft.autoExcluded = parseCommaList(value(w, 404));
draft.autoPrefixChars = value(w, 408); draft.autoPrefixChars = value(w, 408);
draft.autoSuffixChars = value(w, 409); draft.autoSuffixChars = value(w, 409);
@@ -985,6 +1026,7 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
validateSettings(draft); validateSettings(draft);
view->settings->draft.autoLanguages = draft.autoLanguages; view->settings->draft.autoLanguages = draft.autoLanguages;
view->settings->draft.autoNever = draft.autoNever; view->settings->draft.autoNever = draft.autoNever;
view->settings->draft.autoWords = draft.autoWords;
view->settings->draft.autoExcluded = draft.autoExcluded; view->settings->draft.autoExcluded = draft.autoExcluded;
view->settings->draft.autoMaxRank = draft.autoMaxRank; view->settings->draft.autoMaxRank = draft.autoMaxRank;
view->settings->draft.autoMinLength = draft.autoMinLength; view->settings->draft.autoMinLength = draft.autoMinLength;
@@ -1031,13 +1073,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
for (auto &lang : v->draft.autoLanguages) for (auto &lang : v->draft.autoLanguages)
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true); check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525); label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
edit(w, 404, 18, 122, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL); edit(w, 404, 18, 122, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 404, commaList(v->draft.autoExcluded)); value(w, 404, commaList(v->draft.autoExcluded));
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525); label(w, L"Never correct these words (comma-separated)", 18, 184, 525);
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL); edit(w, 405, 18, 212, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 405, commaList(v->draft.autoNever)); value(w, 405, commaList(v->draft.autoNever));
label(w, L"Corrects after Space and a short pause.", 18, 344, 525); label(w, L"My dictionary: correct words, separated by commas or new lines", 18, 276, 525);
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 525); 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);
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));
}
label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390); label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
edit(w, 406, 420, 396, 122, 26, ES_NUMBER); edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
value(w, 406, std::to_wstring(v->draft.autoMaxRank)); value(w, 406, std::to_wstring(v->draft.autoMaxRank));
+4 -1
View File
@@ -2,9 +2,12 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.8"; inline constexpr wchar_t AppVersion[] = L"0.6.10";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
bool notifyUpdateAvailable(const std::wstring &version);
std::wstring updateToastXml(const std::wstring &version);
struct UpdateResult struct UpdateResult
{ {
std::wstring state, version, error; std::wstring state, version, error;
+62
View File
@@ -4,8 +4,17 @@
#include <commctrl.h> #include <commctrl.h>
#include <iostream> #include <iostream>
#include <cmath> #include <cmath>
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
{
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
return DefSubclassProc(w, message, wp, lp);
}
namespace ks { namespace ks {
struct InputRegressionAccess { struct InputRegressionAccess {
static inline bool delivery = true;
static inline int notifications = 0;
static bool notify(const std::wstring &) { ++notifications; return delivery; }
static void notificationSetup(App &a) { a.updateNotifier = notify; }
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; } static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static bool updateTimer(App &a) { static bool updateTimer(App &a) {
a.updateRunning=true; // Exercise timer scheduling without launching a network request. a.updateRunning=true; // Exercise timer scheduling without launching a network request.
@@ -42,6 +51,7 @@ int main(int argc, char **argv) {
{ {
ks::App app(GetModuleHandleW(nullptr),true,temp); ks::App app(GetModuleHandleW(nullptr),true,temp);
ks::InputRegressionAccess::setup(app); ks::InputRegressionAccess::setup(app);
ks::InputRegressionAccess::notificationSetup(app);
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor. // Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr); app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
require(app.window!=nullptr,"message-only test window"); require(app.window!=nullptr,"message-only test window");
@@ -49,8 +59,18 @@ int main(int argc, char **argv) {
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks"); require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version"); require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated"); require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version"); require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error"); require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
ks::InputRegressionAccess::delivery=false;
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
ks::InputRegressionAccess::delivery=true;
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
auto toast=ks::updateToastXml(L"1&2<3\"");
require(toast.find(L"1&amp;2&lt;3&quot;")!=toast.npos,"update version is XML escaped");
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
"toast body and action both review the update instead of installing automatically");
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon) for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
for(int size:{22,44,55}) { for(int size:{22,44,55}) {
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0); auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
@@ -566,6 +586,29 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080}); fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported"); require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false); ks::showSettings(app,false);
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
require(autoOptions!=nullptr,"auto-correction options open");
ShowWindow(autoOptions,SW_HIDE);
for(int id:{411,412,413}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(autoOptions,id),&control);
MapWindowPoints(nullptr,autoOptions,reinterpret_cast<POINT*>(&control),2);
GetClientRect(autoOptions,&client);
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"personal dictionary field fits options window");
}
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
require(!IsWindow(autoOptions),"personal words accepted without file editing");
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
ShowWindow(autoOptions,SW_HIDE);
wchar_t personalWords[128]{};
GetDlgItemTextW(autoOptions,412,personalWords,128);
require(std::wstring(personalWords)==L"релізик, словечко",
"personal dictionary normalizes case, newlines and duplicate words");
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout"); if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
ks::showHistory(app,false); ks::showHistory(app,false);
auto history=app.historyWindow; auto history=app.historyWindow;
@@ -640,8 +683,27 @@ int main(int argc, char **argv) {
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling"); require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
GetScrollInfo(settings,SB_HORZ,&scroll); GetScrollInfo(settings,SB_HORZ,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling"); require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
int controlMoves = 0;
auto trackedControl = GetDlgItem(settings,201);
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
controlMoves=0;
ValidateRect(settings,nullptr);
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
require(controlMoves==0,"partial wheel step does not rearrange controls");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
require(controlMoves==1,"wheel scroll batches each control movement once");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0); SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0); SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
controlMoves=0;
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
RemoveWindowSubclass(trackedControl,countControlMoves,99);
GetClientRect(settings,&client); GetClientRect(settings,&client);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel); GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2); MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
+75
View File
@@ -3,6 +3,17 @@
#include <fstream> #include <fstream>
#include <commctrl.h> #include <commctrl.h>
namespace ks
{
struct InputRegressionAccess
{
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
return app.typingTarget != nullptr;
}
};
}
namespace namespace
{ {
ks::App *app{}; ks::App *app{};
@@ -11,6 +22,7 @@ ks::Clip saved;
bool captured = false, failed = false; bool captured = false, failed = false;
int stage = 0, ru = -1, en = -1; int stage = 0, ru = -1, en = -1;
size_t punctuationCase = 0; size_t punctuationCase = 0;
bool typeDuringCorrection = false, typedDuringCorrection = false;
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{ const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "}, {L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}}; {L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
@@ -25,7 +37,14 @@ size_t diagnosticStart{};
void require(bool ok, const char *name) void require(bool ok, const char *name)
{ {
if (!ok) if (!ok)
{
wchar_t observed[2048]{};
GetWindowTextW(field, observed, 2048);
std::cerr << "Editor at failure: " << ks::utf8(observed)
<< "; foregroundSame=" << (GetForegroundWindow() == host)
<< "; inputVersion=" << app->inputVersion.load() << '\n';
throw std::runtime_error(name); throw std::runtime_error(name);
}
std::cout << "PASS " << name << '\n'; std::cout << "PASS " << name << '\n';
} }
std::wstring text() std::wstring text()
@@ -80,7 +99,26 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (GetTickCount64() > deadline) if (GetTickCount64() > deadline)
throw std::runtime_error("Native integration timed out"); throw std::runtime_error("Native integration timed out");
if (app->busy) if (app->busy)
{
if (typeDuringCorrection && ks::InputRegressionAccess::deferring(*app))
{
typeDuringCorrection = false;
typedDuringCorrection = true;
std::vector<INPUT> inputs;
for (auto [key, up] : std::vector<std::pair<WORD, bool>>{
{'F', false}, {'F', true}, {VK_LSHIFT, false},
{'F', false}, {'F', true}, {VK_LSHIFT, true},
{'B', false}, {'B', true}, {VK_BACK, false}, {VK_BACK, true}})
{
auto input = ks::keyboardInput(key, up ? KEYEVENTF_KEYUP : 0);
input.ki.dwExtraInfo = 123; // External input must traverse the actual hook.
inputs.push_back(input);
}
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
"concurrent typing submitted during selection/paste transaction");
}
return 0; return 0;
}
switch (stage++) switch (stage++)
{ {
case 0: case 0:
@@ -242,6 +280,41 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break; break;
case 17: case 17:
require(text() == punctuationCases.back().first, "undo preserves custom punctuation"); require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
[[fallthrough]];
case 20:
if (!captured)
{
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
}
app->settings.autoCorrect = true;
app->settings.switchLayout = true;
app->settings.autoLanguages = {L"en", L"ru"};
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
typeDuringCorrection = true;
app->transform(ks::AutoCorrect);
stage = 18;
break;
case 18:
require(typedDuringCorrection && text() == L"привет аА",
"typing during correction replays in order with shift and backspace in new layout");
require(ks::readClipboard(app->window).text() == sentinel,
"concurrent typing preserves clipboard restoration");
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"OLDghbdtn ", false);
record(L"ghbdtn ");
typedDuringCorrection = false;
typeDuringCorrection = true;
app->transform(ks::AutoCorrect);
break;
case 19:
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
"rejected correction collapses selection before replaying typing in original layout");
finish(); finish();
break; break;
} }
@@ -366,6 +439,8 @@ int main(int argc, char **argv)
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN, CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr); 20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000; deadline = GetTickCount64() + 30000;
if (argc > 1 && std::string(argv[1]) == "--typing-only")
stage = 20;
SetTimer(host, 1, 100, nullptr); SetTimer(host, 1, 100, nullptr);
app->execute(); app->execute();
} }
+22
View File
@@ -3,6 +3,14 @@
#include <shlobj.h> #include <shlobj.h>
#include <iostream> #include <iostream>
static std::wstring programs; static std::wstring programs;
static std::wstring activationExecutable, activationCommand;
static int activationCalls{};
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
{
++activationCalls; activationExecutable=path; activationCommand=command;
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
}
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out) static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
{ {
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t)); *out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
@@ -10,8 +18,10 @@ static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
wcscpy(*out,programs.c_str()); return S_OK; wcscpy(*out,programs.c_str()); return S_OK;
} }
#define SHGetKnownFolderPath testKnownFolder #define SHGetKnownFolderPath testKnownFolder
#define CreateProcessW testCreateProcess
#include "../src/screenshot_activation.cpp" #include "../src/screenshot_activation.cpp"
#undef SHGetKnownFolderPath #undef SHGetKnownFolderPath
#undef CreateProcessW
int main() int main()
{ {
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
@@ -45,6 +55,18 @@ int main()
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id); valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
if(!valid) throw std::runtime_error("shortcut activator missing"); if(!valid) throw std::runtime_error("shortcut activator missing");
ks::registerScreenshotNotifications(); // Registration is safe to repeat. ks::registerScreenshotNotifications(); // Registration is safe to repeat.
ks::Activator activator(helper.wstring(),false);
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
if(activationCalls!=1 || activationExecutable!=main || activationCommand!=L"\""+main+L"\" --check-update")
throw std::runtime_error("update toast must open review, never install automatically");
if(activator.Activate(L"other-app",L"check-update",nullptr,0)!=E_INVALIDARG ||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"--install",nullptr,0)!=E_INVALIDARG || activationCalls!=1)
throw std::runtime_error("invalid notification activation must not launch a process");
const wchar_t *token=L"12345678-1234-1234-1234-123456789abc";
activator.Activate(L"HrynCo.KSwitcher.Screenshot",token,nullptr,0);
if(activationCalls!=2 || activationExecutable!=helper.wstring() || activationCommand.find(token)==activationCommand.npos)
throw std::runtime_error("screenshot activation must retain its existing route");
std::cout<<"Passed isolated screenshot registration checks\n"; std::cout<<"Passed isolated screenshot registration checks\n";
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; } } catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr); RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
+55
View File
@@ -241,6 +241,37 @@ int main(int argc, char **argv)
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB"); require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed"); require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
ks::AutoCorrector missing; ks::AutoCorrector missing;
{
ks::AutoCorrector supplemental;
auto options = autoSettings;
options.autoLanguages = {L"en", L"uk", L"ru"};
options.cycle = {L"en", L"uk", L"ru"};
supplemental.configure(L"dictionaries", options.autoLanguages);
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
require(release && release->text == L"реліз " && release->target == 2,
"supplemental Ukrainian release word corrects from Russian layout");
require(!supplemental.suggest(L"реліз ", 2, layouts, options),
"correct Ukrainian release word stays unchanged");
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
"valid Russian release word stays unchanged");
options.autoMaxRank = 50000;
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
"supplemental vocabulary respects explicit frequency limit");
options.autoWords[L"uk"] = {L"релізик"};
options.autoMaxRank = 1;
auto custom = supplemental.suggest(L"релызик ", 1, layouts, options);
require(custom && custom->text == L"релізик " && custom->target == 2,
"personal word enables correction without editing dictionary files");
require(!supplemental.suggest(L"релізик ", 2, layouts, options),
"personal word is protected from correction");
options.autoNever = {L"релызик"};
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
"never-correct list takes precedence over personal word");
options.autoNever.clear();
options.cycle = {L"en", L"ru"};
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
"personal dictionary respects disabled target layouts");
}
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected"); rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion"); require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged"); require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
@@ -335,6 +366,25 @@ int main(int argc, char **argv)
auto temp = std::filesystem::temp_directory_path() / auto temp = std::filesystem::temp_directory_path() /
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId())); (L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(temp); std::filesystem::create_directories(temp);
{
auto folder = temp / L"dictionaries";
std::filesystem::create_directories(folder);
auto put = [&](const wchar_t *name, const std::wstring &text)
{
auto data = ks::utf8(text);
ks::atomicWrite(folder / name, ks::Bytes(data.begin(), data.end()));
};
put(L"en.txt", L"zoo\t3\nhello\t99\nhello\t1\n");
put(L"uk.txt", L"дім\t3\n");
ks::AutoCorrector edited;
edited.configure(folder, {L"en", L"uk"});
auto options = ks::Settings{};
options.cycle = {L"en", L"uk"};
options.autoMaxRank = 1;
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
require(correction && correction->text == L"hello ",
"unsorted dictionaries and duplicates load using lowest duplicate rank");
}
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}}; ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}}; ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
auto thumbnail = ks::decodeImage(firstImage, 1, 1); auto thumbnail = ks::decodeImage(firstImage, 1, 1);
@@ -384,11 +434,16 @@ int main(int argc, char **argv)
settings.autoPrefixChars = L"(#"; settings.autoPrefixChars = L"(#";
settings.autoSuffixChars = L");%"; settings.autoSuffixChars = L");%";
settings.autoNever = {L"myWord"}; settings.autoNever = {L"myWord"};
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
settings.autoExcluded = {L"Code.exe"}; settings.autoExcluded = {L"Code.exe"};
settings.cycle = {L"en", L"uk"}; settings.cycle = {L"en", L"uk"};
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}}; settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings); ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {}); auto restored = ks::loadSettings(temp / L"settings.json", {});
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
auto invalidWords = settings;
invalidWords.autoWords[L"uk"] = {L"hello"};
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings), require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
"configurator snapshot survives serialization without changing the concurrency token"); "configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); }, rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
+6
View File
@@ -3,6 +3,8 @@ from pathlib import Path
import urllib.request, hashlib, json import urllib.request, hashlib, json
ROOT=Path(__file__).resolve().parents[1]/'dictionaries' ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
REV='525f9b560de45753a5ea01069454e72e9aa541c6' REV='525f9b560de45753a5ea01069454e72e9aa541c6'
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
SUPPLEMENTAL={'uk': {'реліз': 50001}}
ROOT.mkdir(exist_ok=True) ROOT.mkdir(exist_ok=True)
manifest={} manifest={}
for lang in ('en','uk','ru'): for lang in ('en','uk','ru'):
@@ -15,8 +17,12 @@ for lang in ('en','uk','ru'):
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
if int(count)<5:continue if int(count)<5:continue
rows.setdefault(word,rank) rows.setdefault(word,rank)
for word,rank in SUPPLEMENTAL.get(lang,{}).items():
rows.setdefault(word,rank)
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows)) out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8')) (ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()} manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
if lang in SUPPLEMENTAL:
manifest[lang]['supplementalWords']=SUPPLEMENTAL[lang]
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8') (ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
print(json.dumps(manifest,indent=2)) print(json.dumps(manifest,indent=2))