Compare commits

..

6 Commits

32 changed files with 661 additions and 52 deletions
+7 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.19 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.23 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()
@@ -17,6 +17,8 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP) 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)
add_executable(KSwitcherScrollTests tests/scroll.cpp)
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
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_notifications.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)
@@ -39,7 +41,7 @@ endif()
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) 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 KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8) target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
@@ -49,14 +51,17 @@ else()
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif() endif()
enable_testing() enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests) add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests) add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>") add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .) install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
+17 -1
View File
@@ -90,6 +90,10 @@ broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot. 40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window. In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
must be saved or copied again before closing without a warning.
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
other properties using handles and controls. Clicking empty space clears selection; dragging empty other properties using handles and controls. Clicking empty space clears selection; dragging empty
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them. space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
@@ -168,7 +172,7 @@ released repeatedly within one stroke. The **Line style** selector supports Soli
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes. for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
The window close button asks for confirmation before discarding an uncopied screenshot. The window close button asks for confirmation before discarding changes that have not been saved or copied.
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image **Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
at its original pixel size, ready to paste. The preview fits the window without changing export size. at its original pixel size, ready to paste. The preview fits the window without changing export size.
The shortcut is configurable in Settings under **Screen capture**. The shortcut is configurable in Settings under **Screen capture**.
@@ -220,6 +224,10 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
## Implemented ## Implemented
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only. - Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
that changes it. If none changes the text, the operation keeps the text and advances one layout.
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary). - Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix. - Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
- Teams recent-word conversion uses a single word-selection command and validates the copied range. - Teams recent-word conversion uses a single word-selection command and validates the copied range.
@@ -278,6 +286,12 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste, History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide. Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
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
clipboard and original history entry unchanged. Text items still open as text;
Enter and double-click still paste the selected item.
Pressing the history shortcut a second time pastes the second-most-recent item. Pressing the history shortcut a second time pastes the second-most-recent item.
## Data and migration ## Data and migration
@@ -379,6 +393,8 @@ protect already valid input, and ambiguous alternatives remain unchanged regardl
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing **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 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. in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
These entries are stored in settings and survive dictionary updates. They protect correct input and 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 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. be enabled. **Never correct these words** is a separate exception list and takes precedence.
Binary file not shown.
+9 -8
View File
@@ -1,21 +1,22 @@
# Latest Windows release # Latest Windows release
## v0.6.19 — 2026-10-31 ## v0.6.23 — 2026-11-01
## Changes ## Changes
- Retry a silently dropped Ctrl+C up to three total attempts, only while the transaction clipboard remains unchanged and the original input context is still valid. Failed snippet verification preserves the trigger and following input without sending Enter. - Add Ukrainian `поточні` to the bundled dictionary: `Gjnjxys ` and `Поточны ` can become `Поточні ` with Ukrainian enabled. Supplemental rank 50001 is included by the default unlimited frequency setting.
- Exclude temporary transaction clipboard contents from Windows clipboard history and cloud synchronization. The original clipboard snapshot is still restored after the operation when ownership permits. - Use paced selection in Chrome and Edge, as already used for Teams/WebView, to reduce incomplete selections during recent-text conversion, repeat conversion and Undo. Full verification remains mandatory.
- Fix snippet integration test isolation: restore a multiline editor after earlier single-line scenarios, reset layout/correction state, coordinate test-editor clipboard access and disable unrelated background update checks. - Save annotated screenshots as full-resolution PNG with **Save… / Ctrl+S**. The first save asks for a destination; later saves update it. **Ctrl+Shift+S** opens Save As. Saving keeps the editor open and does not change the clipboard. Closing warns about changes not saved or copied.
- Add deterministic coverage for one dropped copy and exhausted retries. Keep exact assertions for Enter ordering, buffered typing, clipboard restoration and subsequent auto-correction.
## Validation ## Validation
The fixes passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks and updater progress checks. The full editor integration suite passed all 63 assertions; conversion-specific diagnostics also passed. The final snippet suite passed 20 consecutive runs, including injected copy failures and the three-attempt limit. The v0.6.19 staged Release build also passed the native, input/UI, screenshot and updater checks; the installed application was not replaced or restarted. 423 native regressions, 473 input/UI checks, shared wheel handling, screenshot editor UI, PNG save regressions, notification registration, 48 updater checks and updater progress checks passed.
## Limitations ## Limitations
The fix does not retry paste blindly: an editor may already have changed its selection or text. Clipboard contention, editor capabilities and input privileges can still prevent an operation. Existing accessibility readback limitations and problem-only diagnostic logging remain unchanged. The reported Chrome editor failure has not yet been verified live after the selection change. Existing clipboard/accessibility restrictions and paste readback limitations remain. Automatic correction runs after Space and requires a dictionary match.
**Dictionary upgrade:** the updater preserves existing dictionaries, so an older installation may not receive the new word automatically. Add `поточні` under **Settings → Auto-correction options → My dictionary → Ukrainian**, then click **OK → Save**. Personal words work at any frequency rank limit. Back up custom dictionary files before replacing them manually.
**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, 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, 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.
+1 -1
View File
@@ -1 +1 @@
8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip 414604b295912ad466c1a6c6b6ff85b032b9e4480bf115c249885cef60817475 KSwitcher-win-x64.zip
+8
View File
@@ -10,6 +10,8 @@ $outDir = Join-Path $root 'build'
New-Item -ItemType Directory -Force $outDir | Out-Null New-Item -ItemType Directory -Force $outDir | Out-Null
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' } $executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
Push-Location $root Push-Location $root
try { try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections') $common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
@@ -39,6 +41,10 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot link failed' } if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
if ($Test) { if ($Test) {
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
& .\build\KSwitcherScrollTests.exe
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
& $compiler @common tests/tests.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 @libs -static -s -o build/KSwitcherTests.exe & $compiler @common tests/tests.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 @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' } if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe & .\build\KSwitcherTests.exe
@@ -51,6 +57,8 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' } if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe & .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' } if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
& .\build\KSwitcherScreenshotUITests.exe --save-only
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe' $registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
try { try {
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest & $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
+1 -1
View File
@@ -17,7 +17,7 @@ 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.
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001, Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
after the upstream 50k list, rather than claiming a measured corpus frequency. 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. They are included with the default unlimited rank setting and recorded in sources.json.
+5 -4
View File
@@ -9,11 +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": 42550, "words": 42551,
"bytes": 898227, "bytes": 898248,
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3", "sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
"supplementalWords": { "supplementalWords": {
"\u0440\u0435\u043b\u0456\u0437": 50001 "\u0440\u0435\u043b\u0456\u0437": 50001,
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
} }
}, },
"ru": { "ru": {
+1
View File
@@ -27337,6 +27337,7 @@
поторопись 9062 поторопись 9062
поторопитесь 20656 поторопитесь 20656
поторопиться 15390 поторопиться 15390
поточні 50001
потрапив 4168 потрапив 4168
потрапила 7383 потрапила 7383
потрапили 6631 потрапили 6631
+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,19,0 FILEVERSION 0,6,23,0
PRODUCTVERSION 0,6,19,0 PRODUCTVERSION 0,6,23,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.19" VALUE "FileVersion", "0.6.23"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+27 -9
View File
@@ -227,7 +227,8 @@ class Transaction
return selectPrevious(n, [&] { return valid(); }); return selectPrevious(n, [&] { return valid(); });
} }
std::wstring copyRange(size_t count, const std::wstring &wordCandidate, std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
const std::function<bool(const std::wstring &)> &matches) const std::function<bool(const std::wstring &)> &matches,
const std::function<bool(const std::wstring &)> &suffixMatches = {})
{ {
// WebView word boundaries are only candidates; exact verification is mandatory. // WebView word boundaries are only candidates; exact verification is mandatory.
if (needsSelectionDelay(target) && !wordCandidate.empty() && if (needsSelectionDelay(target) && !wordCandidate.empty() &&
@@ -247,7 +248,25 @@ class Transaction
return {}; return {};
Sleep(25); Sleep(25);
} }
return select(count) ? copy() : std::wstring{}; auto text = select(count) ? copy() : std::wstring{};
// 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() &&
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
caretSteps(text) < count)
{
const auto missing = count - caretSteps(text);
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
for (size_t i = 0; i < missing; ++i)
{
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
return {};
Sleep(25);
}
text = copy();
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
}
return text;
} }
bool paste(const Clip &clip) bool paste(const Clip &clip)
{ {
@@ -433,7 +452,7 @@ void App::addTray()
icon.uCallbackMessage = TrayMessage; icon.uCallbackMessage = TrayMessage;
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED); GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
wcscpy_s(icon.szTip, L"KSwitcher — keyboard layouts & clipboard"); wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
Shell_NotifyIconW(NIM_ADD, &icon); Shell_NotifyIconW(NIM_ADD, &icon);
icon.uVersion = NOTIFYICON_VERSION_4; icon.uVersion = NOTIFYICON_VERSION_4;
Shell_NotifyIconW(NIM_SETVERSION, &icon); Shell_NotifyIconW(NIM_SETVERSION, &icon);
@@ -1138,7 +1157,9 @@ void App::transform(int command, int snippet)
{ {
text = text =
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate) tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
{ return recent.match(candidate, available, physicalLayout()) >= 0; }); { return recent.match(candidate, available, physicalLayout()) >= 0; },
[&](const std::wstring &candidate)
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
hint = recent.match(text, available, physicalLayout()); hint = recent.match(text, available, physicalLayout());
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" + diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint)); std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
@@ -1155,6 +1176,7 @@ void App::transform(int command, int snippet)
previous->version == version) previous->version == version)
{ {
expected = previous->result; expected = previous->result;
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
text = tx.copyRange(caretSteps(previous->result), previous->result, text = tx.copyRange(caretSteps(previous->result), previous->result,
[&](const std::wstring &candidate) [&](const std::wstring &candidate)
{ return newlines(candidate) == newlines(previous->result); }); { return newlines(candidate) == newlines(previous->result); });
@@ -1202,11 +1224,7 @@ void App::transform(int command, int snippet)
chord(VK_RIGHT); chord(VK_RIGHT);
return; return;
} }
int source = detect(text, available, hint); to = nextConversionLayout(text, available, cycle, hint);
if (source < 0)
source = hint;
auto it = std::find(cycle.begin(), cycle.end(), source);
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
} }
result = convertTo(text, available, to, hint); result = convertTo(text, available, to, hint);
} }
+1
View File
@@ -120,6 +120,7 @@ class App
}; };
void showHistory(App &, bool visible = true); void showHistory(App &, bool visible = true);
void launchScreenshot(App &); void launchScreenshot(App &);
void launchScreenshotEditor(App &, const Clip &);
void refreshHistory(App &); void refreshHistory(App &);
void showSettings(App &, bool visible = true); void showSettings(App &, bool visible = true);
void showLogs(App &); void showLogs(App &);
+28
View File
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
} }
return best < 0 ? text : convert(text, layouts[best], to); return best < 0 ? text : convert(text, layouts[best], to);
} }
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
const std::vector<int> &cycle, int hint)
{
if (cycle.empty())
return -1;
int source = detect(text, layouts, hint);
if (source < 0)
source = hint;
auto it = std::find(cycle.begin(), cycle.end(), source);
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
// Compare every candidate with the original input, never an intermediate conversion.
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
for (size_t offset = 0; offset < cycle.size(); ++offset)
{
int target = cycle[(start + offset) % cycle.size()];
if (target != source && convertTo(text, layouts, target, hint) != text)
return target;
}
return cycle[start];
}
std::wstring cycleCase(const std::wstring &text) std::wstring cycleCase(const std::wstring &text)
{ {
if (text == upper(text)) if (text == upper(text))
@@ -459,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
keys.pop_back(); keys.pop_back();
at = now; at = now;
} }
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{
if (text.empty() || text.size() >= keys.size() || overflow)
return -1;
Run tail = *this;
tail.keys = keys.substr(keys.size() - text.size());
return tail.match(text, layouts, physical);
}
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{ {
if (text.size() != keys.size() || overflow) if (text.size() != keys.size() || overflow)
+3
View File
@@ -24,6 +24,8 @@ std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false); std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1); int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint); std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
const std::vector<int> &cycle, int hint);
std::wstring cycleCase(const std::wstring &); std::wstring cycleCase(const std::wstring &);
std::wstring transliterate(const std::wstring &); std::wstring transliterate(const std::wstring &);
struct Snippet struct Snippet
@@ -92,5 +94,6 @@ struct Run
void record(wchar_t, HWND, HKL, ULONGLONG); void record(wchar_t, HWND, HKL, ULONGLONG);
void backspace(HWND, ULONGLONG); void backspace(HWND, ULONGLONG);
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const; int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
}; };
} // namespace ks } // namespace ks
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <map>
#include <set>
#include <string>
namespace ks {
// Keep the visible working set even in unusually tall windows. Never evict a
// visible preview to admit another visible preview: that creates a paint loop.
template<class T>
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
const std::set<std::wstring> &visible, size_t limit=32)
{
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
if(!visible.contains(it->first)) it=cache.erase(it);
else ++it;
}
}
+37
View File
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data())); check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
return pixels; return pixels;
} }
Bytes encodePng(const Pixels &p)
{
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
throw std::runtime_error("Invalid PNG image dimensions");
Com<IWICImagingFactory> factory;
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(factory.out())));
Com<IStream> stream;
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
Com<IWICBitmapEncoder> encoder;
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
Com<IWICBitmapFrameEncode> frame;
check(encoder->CreateNewFrame(frame.out(), nullptr));
check(frame->Initialize(nullptr));
check(frame->SetSize(p.width, p.height));
auto format = GUID_WICPixelFormat32bppBGRA;
check(frame->SetPixelFormat(&format));
if (format != GUID_WICPixelFormat32bppBGRA)
throw std::runtime_error("Unsupported PNG pixel format");
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
const_cast<BYTE *>(p.bgra.data())));
check(frame->Commit());
check(encoder->Commit());
STATSTG stat{};
check(stream->Stat(&stat, STATFLAG_NONAME));
if (stat.cbSize.QuadPart > UINT_MAX)
throw std::runtime_error("PNG image is too large");
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
Bytes bytes((size_t)stat.cbSize.QuadPart);
ULONG read{};
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
if (read != bytes.size())
throw std::runtime_error("Cannot read encoded PNG image");
return bytes;
}
Format imageDib(const Pixels &p) Format imageDib(const Pixels &p)
{ {
BITMAPINFOHEADER h{}; BITMAPINFOHEADER h{};
+1
View File
@@ -9,6 +9,7 @@ struct Pixels
}; };
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0); Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &); Format imageDib(const Pixels &);
Bytes encodePng(const Pixels &);
std::wstring recognizeText(const Clip &); std::wstring recognizeText(const Clip &);
std::wstring recognizeText(const Clip &, const Settings &); std::wstring recognizeText(const Clip &, const Settings &);
std::filesystem::path tesseractExecutable(const Settings &); std::filesystem::path tesseractExecutable(const Settings &);
+3 -2
View File
@@ -396,13 +396,14 @@ bool chord(WORD key, UINT mods)
bool needsSelectionDelay(HWND target) bool needsSelectionDelay(HWND target)
{ {
const auto name = lower(processName(target)); const auto name = lower(processName(target));
return name == L"ms-teams" || name == L"msedgewebview2"; return name == L"ms-teams" || name == L"msedgewebview2" ||
name == L"chrome" || name == L"msedge";
} }
bool selectPrevious(size_t count, const std::function<bool()> &valid) bool selectPrevious(size_t count, const std::function<bool()> &valid)
{ {
if (count > 100000) if (count > 100000)
return false; return false;
// Teams' WebView editor applies selection changes asynchronously. A burst of // Chromium and Teams' WebView editors apply selection changes asynchronously. A burst of
// Shift+Left events can all use the same caret position and select only one // Shift+Left events can all use the same caret position and select only one
// character. Give each change time to settle before sending the next one. // character. Give each change time to settle before sending the next one.
const auto target = GetForegroundWindow(); const auto target = GetForegroundWindow();
+10
View File
@@ -8,6 +8,16 @@
namespace ks namespace ks
{ {
inline std::wstring localBuildTitle(const std::wstring &title)
{
static const bool development=[] {
wchar_t path[32768]{};
if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false;
std::error_code error;
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
}();
return development?title+L" [LOCAL DEV]":title;
}
using Bytes = std::vector<unsigned char>; using Bytes = std::vector<unsigned char>;
struct Format struct Format
{ {
+79 -3
View File
@@ -28,8 +28,8 @@ bool confirmScreenshotClose(HWND owner)
dialog.hMainIcon=icon; dialog.hMainIcon=icon;
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON; dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
dialog.pszWindowTitle=L"KSwitcher"; dialog.pszWindowTitle=L"KSwitcher";
dialog.pszMainInstruction=L"Close without copying?"; dialog.pszMainInstruction=L"Close without saving or copying?";
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard."; dialog.pszContent=L"Your latest screenshot changes have not been saved to a file or copied to the clipboard.";
dialog.nDefaultButton=IDNO; dialog.nDefaultButton=IDNO;
dialog.lpCallbackData=(LONG_PTR)owner; dialog.lpCallbackData=(LONG_PTR)owner;
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT { dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
@@ -208,6 +208,7 @@ struct Capture
int hoveredTool=0; int hoveredTool=0;
MarkPart hoveredPart=MarkPart::None; MarkPart hoveredPart=MarkPart::None;
std::wstring statusText; std::wstring statusText;
std::filesystem::path savedPath;
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {} explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
~Capture() { ~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor); if(pencilCursor) DestroyCursor(pencilCursor);
@@ -239,6 +240,7 @@ void updateStatus(Capture &c)
if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot"; if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot";
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads"; if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition"; if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
if(c.hoveredTool==28) text=L"Save (Ctrl+S) | Save As... (Ctrl+Shift+S)";
} }
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel"; else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps"; else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
@@ -337,6 +339,10 @@ void finishInlineText(HWND w,Capture &c,bool commit)
} }
LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR) LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{ {
if(msg==WM_KEYDOWN && wp=='S' && (GetKeyState(VK_CONTROL)&0x8000)) {
PostMessageW(GetParent(w),WM_COMMAND,(GetKeyState(VK_SHIFT)&0x8000)?29:28,0); return 0;
}
if(msg==WM_CHAR && wp==19) return 0; // Ctrl+S is handled by the editor.
if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) { if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) {
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0; PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
} }
@@ -607,6 +613,9 @@ void layout(HWND w, Capture &c)
MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96), MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96),
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE); MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
} }
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
MoveWindow(GetDlgItem(w,28),MulDiv(540,dpi,96),MulDiv(6,dpi,96),
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
c.panelHeight=c.unit+MulDiv(40,dpi,96); c.panelHeight=c.unit+MulDiv(40,dpi,96);
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE); for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE); MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
@@ -821,6 +830,64 @@ void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard
publish(c.app.window, clip); publish(c.app.window, clip);
c.copied=true; c.copied=true;
} }
void saveToFile(HWND w, Capture &c, const std::filesystem::path &path)
{
finishInlineText(w,c,true);
auto pixels=decodeImage(screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks));
// GDI-rendered screenshot pixels are opaque; GDI does not maintain alpha.
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
auto bytes=encodePng(pixels);
GUID id{};
if(FAILED(CoCreateGuid(&id))) throw std::runtime_error("Cannot prepare screenshot output file");
wchar_t unique[40]{}; StringFromGUID2(id,unique,(int)std::size(unique));
auto temporary=path.parent_path()/(L".kswitcher-"+std::wstring(unique)+L".png");
try {
atomicWrite(temporary,bytes);
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))
throw std::runtime_error("Cannot save screenshot to the selected file");
} catch(...) {
std::error_code ignored; std::filesystem::remove(temporary,ignored); throw;
}
c.savedPath=path;
c.copied=true; // Current edits have been exported, either to file or clipboard.
}
void save(HWND w, Capture &c, bool saveAs=false,
BOOL (WINAPI *chooseFile)(LPOPENFILENAMEW)=GetSaveFileNameW)
{
if(!saveAs && !c.savedPath.empty()) {
saveToFile(w,c,c.savedPath);
return;
}
wchar_t filename[32768]{};
auto proposed=c.savedPath.wstring();
if(proposed.empty()) {
SYSTEMTIME time{}; GetLocalTime(&time);
wchar_t name[100]{};
swprintf_s(name,L"Screenshot-%04u%02u%02u-%02u%02u%02u.png",
time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond);
proposed=name;
}
if(proposed.size()>=std::size(filename)) throw std::runtime_error("Screenshot file path is too long");
std::copy(proposed.begin(),proposed.end(),filename);
OPENFILENAMEW dialog{sizeof(dialog)};
dialog.hwndOwner=w;
dialog.lpstrTitle=L"Save screenshot";
dialog.lpstrFilter=L"PNG image (*.png)\0*.png\0\0";
dialog.nFilterIndex=1;
dialog.lpstrFile=filename;
dialog.nMaxFile=(DWORD)std::size(filename);
dialog.lpstrDefExt=L"png";
dialog.Flags=OFN_OVERWRITEPROMPT|OFN_PATHMUSTEXIST|OFN_NOCHANGEDIR|OFN_EXPLORER;
if(!chooseFile(&dialog)) {
if(CommDlgExtendedError()) throw std::runtime_error("Cannot open the screenshot save dialog");
return;
}
if(lower(std::filesystem::path(filename).extension().wstring())!=L".png")
throw std::runtime_error("Choose a filename ending in .png for the screenshot");
saveToFile(w,c,filename);
c.statusText=L"Saved: "+c.savedPath.wstring();
SendDlgItemMessageW(w,50,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
}
void edit(HWND w, Capture &c) void edit(HWND w, Capture &c)
{ {
auto r = region(c.start,c.end); auto r = region(c.start,c.end);
@@ -841,7 +908,7 @@ void edit(HWND w, Capture &c)
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW); SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN); SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++); auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
SetWindowTextW(w,title.c_str()); SetWindowTextW(w,localBuildTitle(title).c_str());
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES}; INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
InitCommonControlsEx(&statusControls); InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS, CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
@@ -849,6 +916,9 @@ void edit(HWND w, Capture &c)
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object", const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"}; L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
static_assert(std::size(labels)==std::size(ToolIds)); static_assert(std::size(labels)==std::size(ToolIds));
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
0,0,0,0,w,(HMENU)28,c.app.instance,nullptr);
SetWindowSubclass(saveButton,toolHover,1,0);
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX, auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr); CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
for (size_t i=0;i<std::size(ToolIds);++i) for (size_t i=0;i<std::size(ToolIds);++i)
@@ -1023,6 +1093,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); } if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
} }
if (LOWORD(wp)==13) { copy(w,*c); return 0; } if (LOWORD(wp)==13) { copy(w,*c); return 0; }
if (LOWORD(wp)==28 || LOWORD(wp)==29) {
save(w,*c,LOWORD(wp)==29); SetFocus(w); return 0;
}
if (LOWORD(wp)==12 || LOWORD(wp)==18) if (LOWORD(wp)==12 || LOWORD(wp)==18)
{ {
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks); bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
@@ -1079,6 +1152,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
beginMarkText(w,*c,c->selected); return 0; beginMarkText(w,*c,c->selected); return 0;
} }
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; } if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='S') {
save(w,*c,(GetKeyState(VK_SHIFT)&0x8000)!=0); SetFocus(w); return 0;
}
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y')) if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0); SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0) if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
+7 -3
View File
@@ -1,13 +1,15 @@
#include "app.h" #include "app.h"
#include "screenshot_notifications.h"
namespace ks { namespace ks {
void launchScreenshot(App &app) static void launch(App &app, const Clip *clip)
{ {
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe"; auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
if (!std::filesystem::exists(path)) if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package."); throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+ auto command=L"\""+path.wstring()+L"\" ";
(app.settings.screenshotAutoOpenEditor?L"1":L"0"); if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip);
else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0");
if(app.settings.writeLog) command+=L" --write-log"; if(app.settings.writeLog) command+=L" --write-log";
STARTUPINFOW startup{sizeof(startup)}; STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{}; PROCESS_INFORMATION process{};
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
CloseHandle(process.hThread); CloseHandle(process.hThread);
CloseHandle(process.hProcess); CloseHandle(process.hProcess);
} }
void launchScreenshot(App &app) { launch(app,nullptr); }
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
} }
+13
View File
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
return result; return result;
} }
} }
std::wstring cacheScreenshotForEditor(const Clip &clip)
{
// Normalize PNG/DIBV5 and other supported clipboard images at full resolution.
auto pixels=decodeImage(clip);
if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER))
throw std::runtime_error("Image is too large for the screenshot editor");
auto dib=imageDib(pixels);
auto directory=cacheDirectory(); cleanCache(directory);
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
std::wstring id(value+1,36);
atomicWrite(directory/(id+L".capture"),protect(dib.data));
return id;
}
Clip loadNotifiedScreenshot(const std::wstring &id) Clip loadNotifiedScreenshot(const std::wstring &id)
{ {
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification"); if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
+1
View File
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
DWORD startScreenshotActivationListener(); DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &); bool validScreenshotToken(const std::wstring &);
Clip loadNotifiedScreenshot(const std::wstring &); Clip loadNotifiedScreenshot(const std::wstring &);
std::wstring cacheScreenshotForEditor(const Clip &);
} }
+70 -11
View File
@@ -1,5 +1,7 @@
#include "../resources/resource.h" #include "../resources/resource.h"
#include "app.h" #include "app.h"
#include "history_preview.h"
#include "ui_scroll.h"
#include "configuration.h" #include "configuration.h"
#include "ocr.h" #include "ocr.h"
#include "richtext.h" #include "richtext.h"
@@ -238,6 +240,9 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id, scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
GetModuleHandleW(nullptr), nullptr); GetModuleHandleW(nullptr), nullptr);
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE); SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling) if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
{ {
layout->controls.push_back({w, {x, y, x + width, y + height}}); layout->controls.push_back({w, {x, y, x + width, y + height}});
@@ -368,11 +373,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2); int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)}); SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
SetWindowSubclass(w, layoutProc, 2, 0); SetWindowSubclass(w, layoutProc, 2, 0);
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork); RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top, SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
SWP_NOACTIVATE | SWP_NOZORDER); SWP_NOACTIVATE | SWP_NOZORDER);
arrangeWindow(w); arrangeWindow(w);
SetWindowTextW(w, title); SetWindowTextW(w, localBuildTitle(title).c_str());
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetricsForDpi(SM_CXSMICON, dpi), GetSystemMetricsForDpi(SM_CXSMICON, dpi),
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED); GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
@@ -478,7 +484,17 @@ struct HistoryView
std::map<std::wstring, std::string> richTexts; std::map<std::wstring, std::string> richTexts;
HWND rich{}; HWND rich{};
std::set<std::wstring> pending; std::set<std::wstring> pending;
bool scrolling{};
}; };
bool selectedImage(HWND w, HistoryView *v)
{
int i=(int)SendDlgItemMessageW(w,102,LB_GETCURSEL,0,0);
return i>=0 && i<(int)v->items.size() && v->items[i].image;
}
void updateHistoryActions(HWND w, HistoryView *v)
{
SetDlgItemTextW(w,114,selectedImage(w,v)?L"Edit image":L"Open item");
}
std::wstring selected(HWND w, HistoryView *v) std::wstring selected(HWND w, HistoryView *v)
{ {
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0); int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
@@ -507,6 +523,7 @@ void search(HWND w, HistoryView *v)
} }
void requestPreview(HWND w, HistoryView *v, const Entry &entry) void requestPreview(HWND w, HistoryView *v, const Entry &entry)
{ {
if(v->scrolling) return;
const auto id = entry.id; const auto id = entry.id;
const bool image = entry.image; const bool image = entry.image;
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second) if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
@@ -690,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
switch (msg) switch (msg)
{ {
case ScrollActivity:
if((HWND)wp==GetDlgItem(w,102)) {
v->scrolling=lp!=0;
if(!v->scrolling) InvalidateRect((HWND)wp,nullptr,FALSE);
}
return 0;
case WM_COMMAND: case WM_COMMAND:
{ {
int id = LOWORD(wp); int id = LOWORD(wp);
@@ -702,6 +725,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} }
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE) if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
{ {
updateHistoryActions(w,v);
return 0; return 0;
} }
if (id == 102 && HIWORD(wp) == LBN_DBLCLK) if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
@@ -747,10 +771,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (result->generation != v->generation) if (result->generation != v->generation)
return 0; return 0;
auto old = selected(w, v); auto old = selected(w, v);
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
v->items = std::move(result->items); v->items = std::move(result->items);
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0); SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0); SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
int select = 0; int select = 0;
int restoreTop=0;
for (size_t i = 0; i < v->items.size(); ++i) for (size_t i = 0; i < v->items.size(); ++i)
{ {
auto &e = v->items[i]; auto &e = v->items[i];
@@ -758,8 +785,11 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str()); SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
if (e.id == old) if (e.id == old)
select = (int)i; select = (int)i;
if(e.id==topId) restoreTop=(int)i;
} }
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0); SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
updateHistoryActions(w,v);
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0); SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE); InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
return 0; return 0;
@@ -770,16 +800,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (result->generation != v->generation || !IsWindowVisible(w)) if (result->generation != v->generation || !IsWindowVisible(w))
return 0; return 0;
v->pending.erase(result->id); v->pending.erase(result->id);
// At most ~3.3 MiB, irrespective of history length or source image size. auto list=GetDlgItem(w,102);
if (v->thumbnails.size() >= 32) RECT client{}; GetClientRect(list,&client);
v->thumbnails.erase(v->thumbnails.begin()); std::set<std::wstring> visible;
if (v->richTexts.size() >= 32) RECT changed{}; bool repaint=false;
v->richTexts.erase(v->richTexts.begin()); int top=(int)SendMessageW(list,LB_GETTOPINDEX,0,0);
for(int i=std::max(0,top);i<(int)v->items.size();++i) {
RECT row{}, intersection{};
if(SendMessageW(list,LB_GETITEMRECT,i,(LPARAM)&row)==LB_ERR || row.top>=client.bottom) break;
if(!IntersectRect(&intersection,&row,&client)) continue;
visible.insert(v->items[i].id);
if(v->items[i].id==result->id) { changed=intersection; repaint=true; }
}
if (!result->rtf.empty()) if (!result->rtf.empty())
v->richTexts[result->id] = std::move(result->rtf); v->richTexts[result->id] = std::move(result->rtf);
else else
v->thumbnails[result->id] = std::move(result->pixels); v->thumbnails[result->id] = std::move(result->pixels);
InvalidateRect(GetDlgItem(w, 102), nullptr, FALSE); trimHistoryPreviews(v->thumbnails,visible);
trimHistoryPreviews(v->richTexts,visible);
if(repaint) InvalidateRect(list,&changed,FALSE);
return 0; return 0;
} }
case WM_MEASUREITEM: case WM_MEASUREITEM:
@@ -798,15 +837,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break; break;
case WM_CONTEXTMENU: case WM_CONTEXTMENU:
{ {
auto list=GetDlgItem(w,102);
if((HWND)wp!=list) break;
POINT p{(short)LOWORD(lp),(short)HIWORD(lp)};
if(lp!=-1) {
POINT hit=p; ScreenToClient(list,&hit);
auto item=SendMessageW(list,LB_ITEMFROMPOINT,0,MAKELPARAM(hit.x,hit.y));
if(HIWORD(item)) return 0;
SendMessageW(list,LB_SETCURSEL,LOWORD(item),0);
updateHistoryActions(w,v);
} else {
RECT row{};
auto index=SendMessageW(list,LB_GETCURSEL,0,0);
if(index==LB_ERR) return 0;
SendMessageW(list,LB_GETITEMRECT,index,(LPARAM)&row);
p={row.left,row.top}; ClientToScreen(list,&p);
}
HMENU menu = CreatePopupMenu(); HMENU menu = CreatePopupMenu();
AppendMenuW(menu, MF_STRING, 110, L"Paste"); AppendMenuW(menu, MF_STRING, 110, L"Paste");
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text"); AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin"); AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
AppendMenuW(menu, MF_STRING, 113, L"Delete"); AppendMenuW(menu, MF_STRING, 113, L"Delete");
AppendMenuW(menu, MF_STRING, 114, L"Open full item"); 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, 115, L"Copy text from image (OCR)");
POINT p;
GetCursorPos(&p);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr); int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
DestroyMenu(menu); DestroyMenu(menu);
if (cmd) if (cmd)
@@ -1700,11 +1753,17 @@ void showEntry(App &app, const std::wstring &id)
try try
{ {
auto clip = app.history->load(id); auto clip = app.history->load(id);
if(std::any_of(clip.formats.begin(),clip.formats.end(),[](const auto &format) {
return format.id==CF_DIB || format.id==CF_DIBV5 || format.name==L"PNG";
})) {
launchScreenshotEditor(app,clip);
return;
}
auto text = clip.text(); auto text = clip.text();
if (text.empty()) if (text.empty())
{ {
MessageBoxW(app.historyWindow, MessageBoxW(app.historyWindow,
L"This item contains an image or files. Use the preview or paste it into the " L"This item contains files. Paste it into the "
L"destination application.", L"destination application.",
L"Clipboard item", MB_OK); L"Clipboard item", MB_OK);
return; return;
+83
View File
@@ -0,0 +1,83 @@
#pragma once
#include "platform.h"
#include <algorithm>
#include <commctrl.h>
#include <windowsx.h>
namespace ks {
inline constexpr UINT ScrollActivity=WM_APP+42;
inline constexpr UINT_PTR ScrollFrameTimer=0x4b5301, ScrollIdleTimer=0x4b5302;
struct WheelBatch {
long long vertical{}, horizontal{};
WORD keys{};
LPARAM point{};
bool frame{}, active{}, listBox{};
int remainder{};
};
inline LRESULT CALLBACK wheelBatchProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
{
auto *batch=reinterpret_cast<WheelBatch *>(data);
if(msg==WM_MOUSEWHEEL || msg==WM_MOUSEHWHEEL) {
(msg==WM_MOUSEWHEEL?batch->vertical:batch->horizontal)+=GET_WHEEL_DELTA_WPARAM(wp);
batch->keys=GET_KEYSTATE_WPARAM(wp); batch->point=lp;
if(!batch->active) {
batch->active=true;
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,TRUE);
}
// Do not restart the frame timer: an uninterrupted wheel stream must
// keep moving, rather than wait for a debounce interval to expire.
if(!batch->frame) batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
SetTimer(w,ScrollIdleTimer,100,nullptr);
return 0;
}
if(msg==WM_TIMER && wp==ScrollFrameTimer) {
KillTimer(w,ScrollFrameTimer); batch->frame=false;
if(batch->listBox && batch->vertical) {
UINT lines=3; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
if(lines==WHEEL_PAGESCROLL) {
RECT client{}; GetClientRect(w,&client);
auto height=SendMessageW(w,LB_GETITEMHEIGHT,0,0);
lines=std::max<LRESULT>(1,client.bottom/std::max<LRESULT>(1,height));
}
auto delta=batch->vertical+batch->remainder;
batch->remainder=(int)(delta%WHEEL_DELTA);
auto top=SendMessageW(w,LB_GETTOPINDEX,0,0);
auto count=SendMessageW(w,LB_GETCOUNT,0,0);
auto next=std::clamp<long long>(top-(delta/WHEEL_DELTA)*lines,0,std::max<LRESULT>(0,count-1));
if(next!=top) SendMessageW(w,LB_SETTOPINDEX,(WPARAM)next,0);
batch->vertical=0;
} else if(batch->vertical) {
// Native edit/list-view/window handlers retain their own scrolling
// semantics, including system settings and partial wheel deltas.
auto delta=(short)std::clamp<long long>(batch->vertical,-32760,32760);
batch->vertical-=delta;
DefSubclassProc(w,WM_MOUSEWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
}
if(batch->horizontal) {
auto delta=(short)std::clamp<long long>(batch->horizontal,-32760,32760);
batch->horizontal-=delta;
DefSubclassProc(w,WM_MOUSEHWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
}
if(batch->vertical || batch->horizontal)
batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
return 0;
}
if(msg==WM_TIMER && wp==ScrollIdleTimer) {
if(batch->frame) return 0;
KillTimer(w,ScrollIdleTimer); batch->active=false;
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,FALSE);
return 0;
}
if(msg==WM_NCDESTROY) {
KillTimer(w,ScrollFrameTimer); KillTimer(w,ScrollIdleTimer);
RemoveWindowSubclass(w,wheelBatchProc,id); delete batch;
}
return DefSubclassProc(w,msg,wp,lp);
}
inline void enableWheelBatching(HWND window,bool listBox=false)
{
if(!window) return;
auto *batch=new WheelBatch; batch->listBox=listBox;
if(!SetWindowSubclass(window,wheelBatchProc,ScrollFrameTimer,(DWORD_PTR)batch)) delete batch;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.19"; inline constexpr wchar_t AppVersion[] = L"0.6.23";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+6
View File
@@ -1,4 +1,5 @@
#include "app.h" #include "app.h"
#include "ui_scroll.h"
#include "ui_geometry.h" #include "ui_geometry.h"
#include "screenshot.h" #include "screenshot.h"
#include <commctrl.h> #include <commctrl.h>
@@ -728,16 +729,21 @@ int main(int argc, char **argv) {
controlMoves=0; controlMoves=0;
ValidateRect(settings,nullptr); ValidateRect(settings,nullptr);
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window"); 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); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==0,"partial wheel step does not rearrange controls"); require(controlMoves==0,"partial wheel step does not rearrange controls");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
require(controlMoves==0,"wheel changes wait for the shared frame timer");
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==1,"wheel scroll batches each control movement once"); 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; controlMoves=0;
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0); SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0); SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls"); 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"); require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
RemoveWindowSubclass(trackedControl,countControlMoves,99); RemoveWindowSubclass(trackedControl,countControlMoves,99);
+35
View File
@@ -26,10 +26,17 @@ bool conversionOnly = false;
int pasteBehavior = 0; int pasteBehavior = 0;
int enterPresses = 0; int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0; int copyFailuresRemaining = 0, copyRequests = 0;
int droppedSelectionKeys = 0;
bool temporaryClipboardExcluded = true; bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false; bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{ {
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
(GetKeyState(VK_SHIFT) & 0x8000))
{
--droppedSelectionKeys;
return 0;
}
// This bare EDIT control is our test fixture, not a full editor with // This bare EDIT control is our test fixture, not a full editor with
// clipboard retries. Keep clipboard access on its owning UI thread while // clipboard retries. Keep clipboard access on its owning UI thread while
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot // it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
@@ -205,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break; break;
case 3: case 3:
require(text() == L"Hello World", "case conversion"); require(text() == L"Hello World", "case conversion");
droppedSelectionKeys = 5;
app->transform(ks::Case); app->transform(ks::Case);
break; break;
case 4: case 4:
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result"); require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
prepare(L"OLDghbdtn", false); prepare(L"OLDghbdtn", false);
record(L"ghbdtn"); record(L"ghbdtn");
app->transform(ks::Direct + ru); app->transform(ks::Direct + ru);
@@ -392,6 +401,32 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart, require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
"disabled diagnostics preserves conversion and writes no records"); "disabled diagnostics preserves conversion and writes no records");
ks::setLoggingEnabled(true); ks::setLoggingEnabled(true);
prepare(L"PuntoSwitcher ", true);
app->transform(ks::Direct + ru);
break;
case 44:
app->transform(ks::Direct + en);
break;
case 45:
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
droppedSelectionKeys = 5;
app->transform(ks::Direct + ru);
break;
case 46:
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
"repeat conversion recovers Switcher suffix and preserves full result");
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
require(ks::readClipboard(app->window).text() == sentinel,
"suffix recovery restores original clipboard");
// Derive the actual text from the physical keys, preserving an older prefix.
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
record(L"snipping ");
droppedSelectionKeys = 4;
app->transform(ks::Direct + en);
break;
case 47:
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
finish(); finish();
break; break;
case 30: case 30:
+59 -2
View File
@@ -2,10 +2,67 @@
// Including the implementation lets this integration test drive private editor state directly. // Including the implementation lets this integration test drive private editor state directly.
#include "../src/screenshot.cpp" #include "../src/screenshot.cpp"
#include <iostream> #include <iostream>
int main() int main(int argc, char **argv)
{ {
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
try { try {
if(argc>1 && std::string(argv[1])=="--save-only") {
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(root);
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
c.marks={ks::Mark{{40,40},{180,140},0}};
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
auto path=root/L"Знімок.png";
const auto clipboard=GetClipboardSequenceNumber();
ks::saveToFile(nullptr,c,path);
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
auto actual=ks::decodeImage(encoded);
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
"PNG export preserves full resolution, annotations and opaque pixels");
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
c.remember(c.marks); c.marks.clear();
require(!c.copied,"editing after save marks changes unexported");
ks::save(nullptr,c);
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
c.copied=false;
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
return FALSE;
});
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
auto another=root/L"Another.png";
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
return TRUE;
});
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
c.copied=false;
bool failed=false;
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
"saving leaves no temporary files");
bool invalid=false;
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
});} catch(const std::exception &){invalid=true;}
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
CoUninitialize();
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
return 0;
}
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls); INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
ks::ScreenshotHost app{GetModuleHandleW(nullptr)}; ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr); app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
@@ -175,7 +232,7 @@ int main()
} }
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0); c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128); wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints"); require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
RECT client{}; GetClientRect(window,&client); RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar"); require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c); ks::updateStatus(c);
+66
View File
@@ -0,0 +1,66 @@
#include "ui_scroll.h"
#include <iostream>
namespace {
int forwarded{};
long long totalDelta{};
LRESULT CALLBACK observe(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(msg==WM_MOUSEWHEEL) { ++forwarded; totalDelta+=GET_WHEEL_DELTA_WPARAM(wp); }
return DefSubclassProc(w,msg,wp,lp);
}
void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(message); }
void wheel(HWND w,int delta) { SendMessageW(w,WM_MOUSEWHEEL,MAKEWPARAM(0,(WORD)(short)delta),0); }
void flush(HWND w) { SendMessageW(w,WM_TIMER,ks::ScrollFrameTimer,0); }
}
int main()
{
INITCOMMONCONTROLSEX init{sizeof(init),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&init);
auto instance=GetModuleHandleW(nullptr);
auto parent=CreateWindowW(L"STATIC",L"Scroll regression",WS_OVERLAPPEDWINDOW,0,0,500,400,nullptr,nullptr,instance,nullptr);
try {
auto list=CreateWindowW(L"LISTBOX",L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
for(int i=0;i<2000;++i) SendMessageW(list,LB_ADDSTRING,0,(LPARAM)L"row");
SendMessageW(list,LB_SETCURSEL,5,0);
SendMessageW(list,LB_SETTOPINDEX,0,0);
ks::enableWheelBatching(list,true);
for(int i=0;i<1000;++i) wheel(list,-120);
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"burst is deferred to one frame");
flush(list);
UINT lines{}; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"burst moves list");
require(SendMessageW(list,LB_GETCURSEL,0,0)==5,"scroll does not change selection");
for(int i=0;i<1000;++i) wheel(list,120);
flush(list);
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"reverse burst clamps at top");
wheel(list,-60); flush(list);
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"partial wheel step is retained");
wheel(list,-60); flush(list);
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"partial steps combine across frames");
DestroyWindow(list);
for(auto cls:{L"EDIT",WC_LISTVIEWW,L"COMBOBOX",L"STATIC"}) {
auto w=CreateWindowW(cls,L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
require(w!=nullptr,"native scroll control created");
SetWindowSubclass(w,observe,1,0); ks::enableWheelBatching(w);
forwarded=0; totalDelta=0;
for(int i=0;i<100;++i) wheel(w,-120);
require(forwarded==0,"native wheel burst is batched");
flush(w);
require(forwarded==1 && totalDelta==-12000,"one native dispatch preserves burst distance");
wheel(w,60); flush(w);
require(forwarded==2 && totalDelta==-11940,"native partial steps are preserved");
forwarded=0; totalDelta=0;
for(int i=0;i<400;++i) wheel(w,-120);
flush(w); flush(w);
require(forwarded==2 && totalDelta==-48000,"large native burst is not truncated to 16 bits");
SendMessageW(w,WM_TIMER,ks::ScrollIdleTimer,0);
DestroyWindow(w);
}
DestroyWindow(parent);
std::cout<<"Passed shared wheel batching regressions (list, edit, table, combo, window)\n";
return 0;
} catch(const std::exception &e) {
DestroyWindow(parent); std::cerr<<e.what()<<'\n'; return 1;
}
}
+61
View File
@@ -1,4 +1,5 @@
#include "autocorrect.h" #include "autocorrect.h"
#include "history_preview.h"
#include "ocr.h" #include "ocr.h"
#include "richtext.h" #include "richtext.h"
#include "storage.h" #include "storage.h"
@@ -22,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
bool thrown = false; bool thrown = false;
try try
{ {
{
std::map<std::wstring,int> previews;
std::set<std::wstring> visible;
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
ks::trimHistoryPreviews(previews,visible);
require(previews.size()==32,"preview cache stays bounded");
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
ks::trimHistoryPreviews(previews,visible);
require(previews.size()==40,"tall viewport does not thrash visible previews");
visible.clear(); ks::trimHistoryPreviews(previews,visible);
require(previews.size()==32,"cache shrinks after leaving tall viewport");
}
f(); f();
} }
catch (...) catch (...)
@@ -177,6 +192,26 @@ int main(int argc, char **argv)
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words"); require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words"); require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
std::vector<ks::Layout> layouts{en, ru, uk}; std::vector<ks::Layout> layouts{en, ru, uk};
ks::Run suffixRun;
suffixRun.keys = L"snipping ";
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
"recent Cyrillic suffix matches physical keys across layouts");
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
"recent English suffix matches physical keys");
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
"suffix recovery rejects unrelated, empty and complete ranges");
suffixRun.overflow = true;
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
"suffix recovery rejects overflowed tracking");
suffixRun.overflow = false;
suffixRun.keys = L"LinkedIn ";
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
"ChatGPT partial CamelCase selection matches tracked physical suffix");
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
suffixRun.match(L"лувШт ", layouts, en) < 0,
"CamelCase conversion still requires the complete tracked text after recovery");
ks::AutoCorrector corrector; ks::AutoCorrector corrector;
ks::Settings autoSettings; ks::Settings autoSettings;
require(!autoSettings.autoCorrect, "auto-correction defaults off"); require(!autoSettings.autoCorrect, "auto-correction defaults off");
@@ -276,6 +311,14 @@ int main(int argc, char **argv)
options.autoLanguages = {L"en", L"uk", L"ru"}; options.autoLanguages = {L"en", L"uk", L"ru"};
options.cycle = {L"en", L"uk", L"ru"}; options.cycle = {L"en", L"uk", L"ru"};
supplemental.configure(L"dictionaries", options.autoLanguages); supplemental.configure(L"dictionaries", options.autoLanguages);
auto current = supplemental.suggest(L"Gjnjxys ", 0, layouts, options);
require(current && current->text == L"Поточні " && current->target == 2,
"reported title-case Ukrainian word corrects with all three dictionaries enabled");
current = supplemental.suggest(L"Поточны ", 1, layouts, options);
require(current && current->text == L"Поточні " && current->target == 2,
"reported Ukrainian word corrects from Russian layout");
require(!supplemental.suggest(L"Поточні ", 2, layouts, options),
"correct Ukrainian word remains unchanged");
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}}) for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
{ {
auto shared = supplemental.suggest(typed, 0, layouts, options); auto shared = supplemental.suggest(typed, 0, layouts, options);
@@ -292,6 +335,8 @@ int main(int argc, char **argv)
require(!supplemental.suggest(L"релиз ", 1, layouts, options), require(!supplemental.suggest(L"релиз ", 1, layouts, options),
"valid Russian release word stays unchanged"); "valid Russian release word stays unchanged");
options.autoMaxRank = 50000; options.autoMaxRank = 50000;
require(!supplemental.suggest(L"Gjnjxys ", 0, layouts, options),
"supplemental current word respects explicit frequency limit");
require(!supplemental.suggest(L"релыз ", 1, layouts, options), require(!supplemental.suggest(L"релыз ", 1, layouts, options),
"supplemental vocabulary respects explicit frequency limit"); "supplemental vocabulary respects explicit frequency limit");
options.autoWords[L"uk"] = {L"релізик"}; options.autoWords[L"uk"] = {L"релізик"};
@@ -322,6 +367,22 @@ int main(int argc, char **argv)
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");
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion"); require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
"cycle skips unchanged Russian to Ukrainian and wraps to English");
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
"cycle skips unchanged Ukrainian to Russian");
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
"cycle keeps the next language when text changes");
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
"cycle preserves configured order for changed text");
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
"cycle does not use disabled English when all candidates are unchanged");
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
"unchanged digits finish after a bounded cycle");
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
"source outside cycle still skips unchanged candidates");
require(ks::cycleCase(L"hello world") == L"Hello World", "title case"); require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case"); require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case"); require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
+1 -1
View File
@@ -4,7 +4,7 @@ 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 vocabulary has no corpus frequency: rank after the 50k source list.
SUPPLEMENTAL={'uk': {'реліз': 50001}} SUPPLEMENTAL={'uk': {'реліз': 50001, 'поточні': 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'):