Compare commits

..

11 Commits

44 changed files with 2608 additions and 421 deletions
+35
View File
@@ -0,0 +1,35 @@
# Project instructions
## Mandatory Wiki updates for every release
A release is not complete until the published project Wiki reflects that release.
Apply this rule to every release, including patch releases and bug-fix-only releases.
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
1. Confirm the release version from `CMakeLists.txt`, the release tag and
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
preserve the release channel rules in `README.md`.
2. Update the latest-version information on the Wiki home pages and any other
pages identifying the current release. Update release dates, download links
and upgrade instructions where needed. Keep historical version references
and compatibility bridge instructions when they are still accurate.
3. Review the changes since the last release documented in the Wiki, not just
the immediately preceding release. Bring the relevant feature pages up to
date with the shipped behavior: new, changed and removed functionality,
settings, shortcuts, defaults, limitations and troubleshooting. Update
screenshots or examples when they no longer match the application.
For bug-fix-only releases, check whether documented behavior or workarounds
need correction; do not invent feature changes.
4. Keep English and Ukrainian pages synchronized, including `Home` and
`Home-uk`, and preserve working translation and navigation links.
5. Publish the Wiki changes as part of the release workflow. Read back the
published pages and verify the current version, feature descriptions,
download links and translation links. A local Wiki edit alone is not enough.
6. In the release completion report, link the updated Wiki pages and summarize
the documentation changes. If Wiki publication or verification is blocked,
explicitly report the outstanding step and do not claim the release workflow
is complete.
Updating only the release notes or changing only the Wiki version number does
not satisfy this rule; the current functionality must also be reviewed.
+6 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.9 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.20 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -17,6 +17,8 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp)
add_executable(KSwitcherScrollTests tests/scroll.cpp)
target_link_libraries(KSwitcherScrollTests 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)
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)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
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(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
@@ -49,12 +51,14 @@ else()
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(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(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
+87 -20
View File
@@ -16,10 +16,10 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
Double-click the tray icon to open Settings, the bold default menu item.
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
load clipboard history, dictionaries or global input hooks. Clipboard history remains
in the resident app. Screen capture and all screenshot editors run on demand in
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
@@ -160,7 +160,11 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin
for shapes.
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
While holding Shift, moving more than 50 image pixels away from the current line selects a new
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
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
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
@@ -184,7 +188,15 @@ Click the notification or **Review update** to check the latest release and conf
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.
Confirm the offered version to download and install it. The updater uses the existing
Confirm the offered version to download and install it. A progress window shows
download percentage and received/total size, package verification,
installation and restart. After the new process confirms startup, that window shows the installed
version and a Done button; it does not send a separate completion notification. If installation
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
remain silent unless a new version is available. This progress UI is supplied by the updater in
the currently installed build, so it becomes available for subsequent updates after installing it.
The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
before the application exits. If replacement or startup fails, it restores the previous files;
@@ -214,9 +226,17 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
If the editor's word boundary differs from the recorded typing, it falls back to paced character
selection: Teams can coalesce a burst of Shift+Left events into just one character.
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
A silently dropped copy is retried up to three total attempts while the clipboard and input
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
history and cloud synchronization. Paste is not blindly retried.
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
For example, `rider .{enter}` inserts `rider .` and presses Enter.
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
and no Enter action is sent.
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
full-text search, pinning, deletion, full text viewing and image previews.
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
@@ -258,6 +278,21 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
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.
Local builds made with `build.ps1` show **[LOCAL DEV]** in window titles and
the tray tooltip. The marker is enabled by `.local-dev` beside the executable;
release packages do not include that marker.
Fast/inertial wheel events use a shared batching handler in history, layout and
snippet lists, the log table, multiline text/details fields, combo boxes and
scrollable settings windows. High-resolution partial wheel steps are retained.
History pauses new preview loads while scrolling and keeps visible thumbnails
cached, preventing continuous flicker and needless redraws.
Pressing the history shortcut a second time pastes the second-most-recent item.
## Data and migration
@@ -267,25 +302,38 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures flush this context to `kswitcher.log`; flushed
context is discarded. Ordinary application errors are also logged when logging is enabled.
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
context. Successful operations stay in that bounded memory buffer and are included only when
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
errors are also logged when logging is enabled. This works in normal Release builds.
Incident context contains tracked physical input, the expected word, copied text, conversion result,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
original length and a truncation flag; context fields are limited to 512 UTF-16 code units.
Structured summaries retain up to 4096 UTF-16 code units per text field and mark truncation.
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
record the process, window, input version, and repeated attempts within ten seconds without new
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
An unchanged result containing no letters, or a direct command
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
accepted the paste shortcut; it does not prove that the editor applied the replacement.
input. An unchanged manual conversion is recorded as a problem even when the detected source
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
checks the focused editor without moving the caret, changing selection, or copying again.
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
**Paste not verified**. Unsupported/protected controls, changed focus, and readback limits are
explicit verification reasons, never proof of success. Readback is bounded to 4096 characters;
provider-specific character units or delayed editor updates may prevent verification.
With logging disabled, this additional accessibility check is skipped.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
Select a row to inspect original, expected and read-back text, verification status and stages
in the panel below. **Copy selected** or Ctrl+C copies the selected rows with their details;
Ctrl+A selects all rows. Older unstructured log entries remain readable. Viewing saved logs
also works when logging is disabled.
**Clear log** clears only the selected current or previous file and refreshes the table.
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
@@ -343,15 +391,27 @@ 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
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.
**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
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
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
loading sorts them and merges duplicates using the lowest rank. Back up your edited
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
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
It only corrects unknown words when exactly one alternative is a dictionary word.
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
key presses and releases; no typing pause is required. Queued input is 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 the dictionary alternatives agree on the resulting text.
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
Different valid spellings remain ambiguous and are left unchanged.
**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.
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
@@ -425,6 +485,13 @@ against a staged build. It briefly opens real selection/editor windows without c
### Release channel rule
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
the latest version and relevant feature documentation in both English and Ukrainian.
Review all changes since the last documented release, update affected instructions,
shortcuts, settings and screenshots, and verify the published pages and links.
The release workflow is incomplete until this is done. Follow the mandatory
[Wiki release checklist](AGENTS.md#mandatory-wiki-updates-for-every-release).
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
Binary file not shown.
+13 -10
View File
@@ -1,26 +1,29 @@
# Latest Windows release
## v0.6.9 — 2026-10-29
## v0.6.20 — 2026-10-31
## Changes
- Show automatic update announcements using the same native Windows notification channel as screenshots. Review update opens the update check and confirmation; Later dismisses the notification.
- Retry notification delivery after five minutes if Windows rejects it, and log update checks and delivery results. Successfully submitted notifications are deduplicated for the current version during the running session.
- Fix Settings flicker when using the mouse wheel: skip layout work at scroll boundaries, move only changed controls in one batch, and avoid erasing the entire window background.
- Open clipboard-history images in the built-in screenshot editor with **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor** in the context menu. PNG, DIB and DIBV5 open at full resolution through an encrypted local cache, without requiring notifications or changing the clipboard/original history entry. Copy the annotated result explicitly from the editor.
- Fix continuous history flicker by keeping visible previews cached, invalidating only completed preview rows and preserving the top entry across background refreshes.
- Handle fast/inertial wheels through one shared batching mechanism for history, layout/snippet lists, the log table, multiline text/details fields, combo boxes and scrollable settings windows. Preserve partial wheel deltas, clamp list boundaries and pause new history previews until scrolling stops.
- Mark local developer builds with **[LOCAL DEV]** in window titles and the tray tooltip. Release packages omit the development marker. This does not create a separate data profile.
- Right-clicking a history entry selects that entry before showing its actions. Enter and double-click continue to paste; text items still open in the full-text viewer.
## Validation
203 native checks, 456 input/layout checks, screenshot editor UI integration, isolated notification registration and activation checks, and 45 updater checks. Regression coverage includes notification retry/deduplication, review-only activation, partial wheel steps, scroll boundaries and batched control movement.
Passed 406 native regressions, 473 input/UI checks, shared wheel batching tests, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. Manual testing confirmed image editing from history and resolved clipboard flicker and Logitech inertial-wheel beeping. Shared wheel tests cover lists, edits, tables, combo boxes and windows, partial steps, large bursts, selection preservation and list boundaries.
## Limitations
Image decoding or a normalized image larger than the 128 MiB editor-transfer limit can prevent opening; an error is shown. Existing clipboard, OCR and editor privilege limitations remain. The wheel over screenshot annotations changes size/thickness rather than scrolling.
**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.
Windows notification banners follow system notification and Do not disturb settings. The new update notification behavior takes effect after installing v0.6.9.
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, development marker, Tesseract installation or language models.
Build and package:
```powershell
.\build.ps1 -Test
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
+1 -1
View File
@@ -1 +1 @@
cd6b3dd62065e50b68cf77344fbad8d7be41467142f31527933342f00e3c0f63 KSwitcher-win-x64.zip
0b3c4fd1346d0b4eeb73d276193c5d2789fd32986c3cc75384cf489f23629678 KSwitcher-win-x64.zip
+8
View File
@@ -10,6 +10,8 @@ $outDir = Join-Path $root 'build'
New-Item -ItemType Directory -Force $outDir | Out-Null
$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
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
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')
@@ -39,6 +41,10 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
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
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe
@@ -61,6 +67,8 @@ try {
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
if ($LASTEXITCODE) { throw 'Updater tests failed' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
}
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/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
+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
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
(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.
Back up edited files before replacing dictionaries with a new distribution.
+6 -3
View File
@@ -9,9 +9,12 @@
"uk": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
"words": 42549,
"bytes": 898210,
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
"words": 42550,
"bytes": 898227,
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
"supplementalWords": {
"\u0440\u0435\u043b\u0456\u0437": 50001
}
},
"ru": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
+1
View File
@@ -31207,6 +31207,7 @@
релігію 26138
релігія 22202
релігії 23427
реліз 50001
рембо 28603
ремень 23872
ремені 28385
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,9,0
PRODUCTVERSION 0,6,9,0
FILEVERSION 0,6,20,0
PRODUCTVERSION 0,6,20,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.9"
VALUE "FileVersion", "0.6.20"
END
END
BLOCK "VarFileInfo"
+215 -5
View File
@@ -3,10 +3,192 @@
[string]$CurrentVersion, [string]$ExpectedVersion,
[string]$InstallDirectory, [string]$WorkDirectory,
[int]$ParentProcessId, [string]$ReadyEvent,
[switch]$ShowProgress,
[switch]$Library
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$script:progressWindow = $null
function Start-UpdateProgress {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
if (!('KSwitcherUpdateProgress' -as [type])) {
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
using System;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
public sealed class UpdateMeter : Control {
public int Percent = -1;
private int position;
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
public UpdateMeter() {
DoubleBuffered = true;
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
timer.Start();
}
protected override void OnPaint(PaintEventArgs e) {
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
}
}
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
}
public sealed class UpdateSteps : Control {
public int Current = -1;
public bool Complete;
public UpdateSteps() { DoubleBuffered = true; }
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
string[] names = {"Download","Verify","Install","Restart"};
for(int i=0;i<4;i++) {
int x = i*(Width-80)/3;
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
using(var pen = new Pen(Color.FromArgb(231,233,240)))
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
using(var brush = new SolidBrush(color)) {
if(Complete || i<Current) {
e.Graphics.FillEllipse(brush,x,0,16,16);
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
} else {
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
}
e.Graphics.DrawString(names[i],Font,brush,x,26);
}
}
}
}
public sealed class KSwitcherUpdateProgress : IDisposable {
private Form form;
private Label stage, detail, percentage, footer;
private UpdateMeter progress;
private UpdateSteps steps;
private Button done, close;
private bool closing;
private Exception failure;
private readonly ManualResetEvent ready = new ManualResetEvent(false);
private readonly Thread thread;
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
public KSwitcherUpdateProgress() {
thread = new Thread(delegate() {
try {
Application.EnableVisualStyles();
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
logo.Paint += delegate(object sender,PaintEventArgs e) {
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
};
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
bottom.Controls.AddRange(new Control[]{footer,done});
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
Application.Run(form);
} catch(Exception e) { failure = e; ready.Set(); }
});
thread.IsBackground = true;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
if (failure != null) throw new Exception("Cannot display update progress.",failure);
}
public void Report(string title, string text, int percent) {
if (form == null || form.IsDisposed) return;
form.BeginInvoke((Action)delegate {
stage.Text = title; detail.Text = text;
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
int separator = text.IndexOf("% - ");
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
if(title.StartsWith("Downloading")) steps.Current=0;
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
else if(title.StartsWith("Restarting")) steps.Current=3;
progress.Invalidate(); steps.Invalidate();
});
}
public void SavePreview(string path) {
form.Invoke((Action)delegate {
using(var bitmap = new Bitmap(form.Width,form.Height)) {
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
}
});
}
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
public void Finish(string title, string text, bool success) {
form.Invoke((Action)delegate {
closing = true;
stage.Text = title; detail.Text = text;
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
steps.Complete = success;
if(!success) {
detail.Visible = false; steps.Visible = false;
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
}
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
close.Enabled = true; done.Visible = true;
form.AcceptButton = done; form.CancelButton = done;
progress.Invalidate(); steps.Invalidate();
});
}
public void WaitForClose() { thread.Join(); }
public void Dispose() {
if (form != null && !form.IsDisposed)
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
thread.Join(3000);
}
}
'@
}
$script:progressWindow = New-Object KSwitcherUpdateProgress
}
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
if ($script:progressWindow) {
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
catch { Write-Warning 'Could not refresh update progress.' }
}
}
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
if ($script:progressWindow) {
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
catch { Write-Warning 'Could not display the update result.' }
}
return $false
}
function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
throw 'Release asset URL is outside the KSwitcher repository.'
}
}
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$request = [Net.HttpWebRequest]::Create($Url)
$request.AllowAutoRedirect = $false
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
$buffer = New-Object byte[] 65536
$total = 0L
$deadline = [DateTime]::UtcNow.AddSeconds(60)
$lastProgress = [DateTime]::MinValue
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
$total += $count
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
$outputStream.Write($buffer,0,$count)
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
Show-UpdateProgress $ProgressLabel $detail $percent
$lastProgress = [DateTime]::UtcNow
}
}
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
} finally {
if ($outputStream) { $outputStream.Dispose() }
if ($inputStream) { $inputStream.Dispose() }
@@ -220,8 +411,9 @@ function Write-Result($Value) {
if ($Library) { return }
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
$changed = [Collections.Generic.List[string]]::new()
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
try {
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
$current = Get-StableVersion $CurrentVersion
if (!$current) { throw 'Invalid installed version.' }
if ($Mode -eq 'Install') {
@@ -242,25 +434,35 @@ try {
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
[IO.Directory]::CreateDirectory($stage) | Out-Null
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
Receive-File $release.zip $archive 50MB
Show-UpdateProgress 'Downloading update' $release.version 0
Receive-File $release.zip $archive 50MB 'Downloading update'
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
Expand-Release $archive $stage (Get-StableVersion $release.version)
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
Write-Result @{state='prepared';version=$release.version}
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
$parentExited = $true
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
Install-Files $stage $InstallDirectory $backup $changed
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
$success = $true
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
}
} catch {
$message = $_.Exception.Message
if ($parentExited) {
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
try {
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
Undo-Files $InstallDirectory $backup $changed
@@ -273,8 +475,12 @@ try {
}
Write-Result @{state='error';error=$message}
if ($ready -and $ready.WaitOne(0)) {
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
} else {
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
}
}
exit 1
} finally {
@@ -291,4 +497,8 @@ try {
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
if ($script:progressWindow) {
if ($uiFinished) { $script:progressWindow.WaitForClose() }
$script:progressWindow.Dispose(); $script:progressWindow = $null
}
}
+288 -53
View File
@@ -2,6 +2,8 @@
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include "diagnostics.h"
#include "paste_probe.h"
#include "screenshot_notifications.h"
#include <algorithm>
#include <commctrl.h>
@@ -12,6 +14,17 @@ namespace ks
App *App::current = nullptr;
namespace
{
const char *transformCommandName(int command)
{
return command == Toggle ? "Convert / cycle" : command >= Direct ? "Convert / direct" :
command == Case ? "Change case" : command == Translit ? "Transliterate" :
command == Undo ? "Undo" : "Automatic / snippet";
}
ULONG_PTR snippetReplayMarker()
{
static const char marker{};
return reinterpret_cast<ULONG_PTR>(&marker);
}
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
{
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
@@ -111,7 +124,13 @@ class Transaction
{
check();
adopted.clear();
app.ownSequence = expected = writeClipboard(app.window, c);
// These are temporary transaction contents, not a user's clipboard entry.
// In particular, do not invite the Windows history service to read our
// sentinel while the target editor is trying to open the clipboard.
auto temporary = c;
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
app.ownSequence = expected = writeClipboard(app.window, temporary);
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
// Never adopt that writer's sequence as our own and paste its content.
if (GetClipboardOwner() != app.window)
@@ -133,6 +152,8 @@ class Transaction
return {};
}
ULONGLONG deadline = GetTickCount64() + 2000;
ULONGLONG retryAt = GetTickCount64() + 250;
unsigned attempts = 1;
DWORD changed = before;
ULONGLONG stable = 0;
bool changedAgain = false;
@@ -149,6 +170,21 @@ class Transaction
}
if (changed != before && GetTickCount64() - stable >= 60)
break;
// Native editors can silently drop Ctrl+C if another process has
// the clipboard open. Retry only while our sentinel is untouched;
// never overwrite a copy result or another application's content.
if (changed == before && attempts < 3 && GetTickCount64() >= retryAt &&
!GetOpenClipboardWindow())
{
check();
if (!valid() || GetClipboardSequenceNumber() != before)
return {};
if (!chord(L'C', MOD_CONTROL))
return {};
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
++attempts;
retryAt = GetTickCount64() + 250;
}
Sleep(10);
}
if (changed == before || !valid())
@@ -248,6 +284,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
registrationWarning = L"Settings were not loaded: " + wide(e.what());
}
setLoggingEnabled(settings.writeLog);
if (settings.autoCorrect)
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
catch (const std::exception &e)
{
settings.autoCorrect = false;
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
}
try
{
history = std::make_unique<History>(data / L"history");
@@ -390,7 +433,7 @@ void App::addTray()
icon.uCallbackMessage = TrayMessage;
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
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);
icon.uVersion = NOTIFYICON_VERSION_4;
Shell_NotifyIconW(NIM_SETVERSION, &icon);
@@ -417,12 +460,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
void App::applySettings(const Settings &s)
{
bool autostartChanged = false;
AutoCorrector predictor;
validateSettings(s);
validateTesseract(s);
if (s.autoCorrect)
{
AutoCorrector probe;
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
}
if (!registerHotkeys(s))
{
@@ -454,6 +497,7 @@ void App::applySettings(const Settings &s)
{
std::lock_guard lock(stateMutex);
settings = s;
autoPredictor = std::move(predictor);
setLoggingEnabled(s.writeLog);
run.reset();
last.reset();
@@ -517,6 +561,7 @@ void App::resetInput()
run.reset();
last.reset();
}
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{
if (ownKeyboardInput(k))
@@ -557,9 +602,33 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
return;
}
run.record(ch, fg, layout, GetTickCount64());
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() &&
mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
run.keys.size() <= 41 && !busy && !run.overflow)
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
{
for (int source = 0; source < (int)layouts.size(); ++source)
if (layouts[source].handle == layout)
{
std::wstring predicted;
for (wchar_t key : run.keys)
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
if (autoPredictor.suggest(predicted, source, layouts, settings))
{
// Reserve the transaction before the next physical key arrives.
// The delimiter itself passes through; subsequent input is queued.
typingTarget = fg;
autoPending = true;
busy = true;
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
{
typingTarget = nullptr;
autoPending = false;
busy = false;
}
}
break;
}
}
if (busy || settings.snippets.empty())
return;
auto keys = lower(run.keys);
@@ -587,7 +656,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
for (auto &trigger : variants)
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
{
PostMessageW(window, SnippetMessage, (WPARAM)i, 0);
// Keep subsequent typing out of the selection/paste transaction.
typingTarget = fg;
snippetPending = (int)i;
snippetTyping = true;
busy = true;
if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load()))
{
typingTarget = nullptr;
snippetPending = -1;
snippetTyping = false;
busy = false;
}
return;
}
}
@@ -598,6 +678,27 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate)
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{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
@@ -640,6 +741,48 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
return true;
return false;
}
bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet)
{
std::lock_guard lock(stateMutex);
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
return false;
typingTarget = target;
snippetTyping = snippet;
return true;
}
void App::replayTyping()
{
std::vector<INPUT> inputs;
HWND target;
{
std::lock_guard lock(stateMutex);
target = typingTarget;
typingTarget = nullptr;
autoPending = false;
snippetPending = -1;
inputs.swap(deferredTyping);
if (snippetTyping)
for (auto &input : inputs)
input.ki.dwExtraInfo = snippetReplayMarker();
snippetTyping = false;
}
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)
{
if (code == HC_ACTION && current &&
@@ -737,12 +880,30 @@ void App::transform(int command, int snippet)
L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts));
}
if (busy.exchange(true))
const bool preDeferred = (command == AutoCorrect && autoPending) ||
(snippet >= 0 && snippetPending == snippet);
if (busy.exchange(true) && !preDeferred)
{
if (manual)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
{
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy");
try
{
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
{"elapsedMs", GetTickCount64() - requestedAt}};
diagnosticOperation(record.dump());
}
catch (...) {}
}
return;
}
if (preDeferred)
{
autoPending = false;
snippetPending = -1;
}
Run recent;
std::optional<LastTransform> previous;
Settings config;
@@ -757,19 +918,24 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
{
struct BusyReset
{
std::atomic<bool> &b;
HWND window;
bool deferred{};
~BusyReset()
{
b = false;
if (deferred)
PostMessageW(window, ReplayTypingMessage, 0, 0);
else
b = false;
}
} reset{busy};
} reset{busy, window, preDeferred};
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected;
std::wstring text, result, expected, actual, verification = L"not-attempted";
int hint = -1, to = command >= Direct ? command - Direct : -1;
if (manual)
{
@@ -794,21 +960,34 @@ void App::transform(int command, int snippet)
bool enabled;
std::wstring &reason;
std::wstring &text, &result, &expected;
std::wstring &actual, &verification;
int &hint, &to;
int command;
const std::vector<Layout> &layouts;
~Audit()
{
if (enabled)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result),
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected");
try
{
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
!(command == 0 && reason == L"paste-sent-unverified")},
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
{"expected", bounded(result)}, {"actual", bounded(actual)},
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
}
catch (...) {} // Logging must never abort a completed input transaction.
}
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
actual, verification, hint, to, command, available};
try
{
auto valid = [&]
@@ -844,11 +1023,27 @@ void App::transform(int command, int snippet)
std::wstring predicted;
for (wchar_t key : recent.keys)
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
expected = predicted;
if (!autoCorrector.suggest(predicted, hint, available, config) ||
!autoCorrectionAllowed(target, config) || !valid())
return;
}
reason = L"clipboard-snapshot";
if (command == AutoCorrect || snippet >= 0)
{
if (!beginTypingDeferral(target, version, snippet >= 0))
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);
reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
@@ -865,11 +1060,15 @@ void App::transform(int command, int snippet)
// conversion, automatic correction must never fall back to a suffix.
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
return;
autoSelected = true;
Sleep(25);
text = tx.copy();
// Some editors first select only ") ". Extend by whole-word
// boundaries, then still verify against the complete tracked run.
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
// Editors may split hj,bnm at the comma. Extend only a verified
// suffix of the tracked input, and still require a complete match.
for (int i = 0; i < 3 && tx.valid() &&
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
(autoClosingSelection(text, config) ||
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
{
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
break;
@@ -881,7 +1080,10 @@ void App::transform(int command, int snippet)
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
{
if (tx.valid())
{
chord(VK_RIGHT);
autoSelected = false;
}
return;
}
to = suggestion->target;
@@ -924,13 +1126,11 @@ void App::transform(int command, int snippet)
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
if (!tx.select(sn.trigger.size()))
return;
autoSelected = true;
reason = L"snippet-verification-mismatch";
text = tx.copy();
if (tail.match(text, available, physicalLayout()) < 0)
{
if (tx.valid())
chord(VK_RIGHT);
return;
}
result = parseTemplate(sn.text).text;
automatic = true;
}
@@ -1012,18 +1212,38 @@ void App::transform(int command, int snippet)
}
}
bool sent = false;
PasteProbe probe(config.writeLog && manual && result != text, text);
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" changed=" + std::to_wstring(result != text));
if (result != text)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
chord(VK_RIGHT);
if (sent || result == text)
autoSelected = false;
reason = result == text ? L"conversion-unchanged" :
sent ? L"paste-sent-unverified" : L"paste-cancelled";
if (result == text &&
(std::none_of(text.begin(), text.end(), letter) ||
(command >= Direct && detect(text, available, hint) == to)))
reason = L"unchanged-expected";
if (manual && result == text)
{
reason = L"Conversion unchanged";
verification = L"calculated-result-equals-original";
}
if (sent && manual && config.writeLog)
{
verification = probe.status;
if (tx.valid())
{
auto observed = probe.read((int)caretSteps(result));
verification = probe.status;
if (observed && tx.valid())
{
actual = *observed;
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
}
else reason = L"Paste not verified";
}
else verification = L"context-changed-after-paste";
}
if (sent && !tx.valid())
reason = L"context-changed-after-paste";
if (sent && command == Undo && previous && previous->autoCorrected)
@@ -1041,12 +1261,22 @@ void App::transform(int command, int snippet)
if (snippet >= 0 && sent && tx.valid())
{
auto t = parseTemplate(config.snippets[snippet].text);
if (!t.suffix.empty() && tx.select(caretSteps(t.suffix)))
if (!t.suffix.empty())
{
if (!tx.select(caretSteps(t.suffix))) return;
auto suffix = tx.copy();
if (tx.valid())
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
if (!tx.valid()) return;
if (newlines(suffix) != newlines(t.suffix))
{
chord(VK_RIGHT);
return;
}
if (!chord(VK_LEFT)) return;
}
// Action markers run after insertion and cursor placement,
// before any physical typing buffered during expansion.
for (int i = 0; i < t.enters && tx.valid(); ++i)
if (!chord(VK_RETURN)) break;
}
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
{
@@ -1305,7 +1535,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
updateWorker = std::thread([this, interactive, install, version]
{
auto result = std::make_unique<UpdateResult>();
try { *result = runUpdate(install, version); }
try { *result = runUpdate(install, version, interactive); }
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
result->interactive = interactive;
result->install = install;
@@ -1377,6 +1607,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
case ReplayTypingMessage:
app->replayTyping();
return 0;
case TrayMessage:
if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu();
@@ -1391,13 +1624,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->captureHistory();
return 0;
case AutoCorrectMessage:
if (app->installingUpdate) return 0;
app->pendingAutoVersion = (uint64_t)wp;
SetTimer(hwnd, 3, 250, nullptr);
if (app->autoPending)
{
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
app->transform(AutoCorrect);
else
app->replayTyping();
}
return 0;
case SnippetMessage:
if (app->installingUpdate) return 0;
app->transform(0, (int)wp);
if (app->snippetPending == (int)wp)
{
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
app->transform(0, (int)wp);
else
app->replayTyping();
}
return 0;
case HistoryMessage:
if (app->historyWindow && IsWindowVisible(app->historyWindow))
@@ -1419,7 +1661,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_COPYDATA:
{
auto *data = (COPYDATASTRUCT *)lp;
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
{
if (app->installingUpdate) return FALSE;
return configurationRequest(*app, (HWND)wp, *data);
@@ -1449,13 +1691,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->dispatch(20);
return 0;
}
if (wp == 3)
{
KillTimer(hwnd, 3);
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
if (wp == 2)
{
PostMessageW(hwnd, WM_CLOSE, 0, 0);
@@ -1550,8 +1785,8 @@ int App::execute()
SetTimer(window, 2, 10000, nullptr);
else
SetTimer(window, 5, 5000, nullptr);
if (keyboardHook && mouseHook)
confirmUpdatedStartup(updateStartupEvent);
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
log(std::wstring(L"Update completed; running version ") + AppVersion);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
try { registerScreenshotNotifications(); }
+11 -1
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,
Screenshot = 7, Direct = 100;
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
{
@@ -37,6 +38,7 @@ class App
Settings settings;
std::unique_ptr<History> history;
AutoCorrector autoCorrector;
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0};
std::atomic<uint64_t> inputVersion{0};
@@ -69,6 +71,7 @@ class App
void applySettings(const Settings &);
void reloadLayouts();
void clearHistory();
void clearLog(bool previous);
void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
@@ -98,19 +101,26 @@ class App
bool (*updateNotifier)(const std::wstring &){};
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
uint64_t transformAttempt{}, previousAttemptVersion{};
ULONGLONG previousAttemptAt{};
HWND previousAttemptWindow{};
int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook;
HWND typingTarget{};
bool autoPending{};
int snippetPending = -1;
bool snippetTyping{};
std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
void replayTyping();
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &, bool visible = true);
void launchScreenshot(App &);
void launchScreenshotEditor(App &, const Clip &);
void refreshHistory(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
+36 -9
View File
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
}
if (offsets.empty())
throw std::runtime_error("Empty auto-correction dictionary");
for (size_t i = 1; i < offsets.size(); ++i)
if (word(i - 1) >= word(i))
throw std::runtime_error("Dictionary must be uniquely sorted");
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
auto frequency = [&](uint32_t offset)
{
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();
bytes.shrink_to_fit();
}
@@ -143,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
suffix.insert(suffix.begin(), word.back());
word.pop_back();
}
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
// Validate letters and capitalization after conversion, not before it.
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
suffix.size() > 6 || rejected.contains(lower(word)))
return {};
for (auto &never : settings.autoNever)
if (lower(never) == lower(word))
@@ -151,12 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage))
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.
for (auto &[language, d] : dictionaries)
if (d.rank(word))
if (d.rank(word) || personal(language, word))
return {};
std::optional<AutoSuggestion> candidate;
int matches = 0;
std::optional<std::wstring> matchedText;
auto priority = [&](int layout)
{
auto language = layoutLanguage(layouts[layout]);
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
};
for (int i = 0; i < (int)layouts.size(); ++i)
{
if (i == source || !layouts[i].convertible ||
@@ -168,12 +193,14 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto converted = convert(word, layouts[source], layouts[i], true);
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
continue;
int rank = dictionary->second.rank(converted);
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
if (!rank)
continue;
if (++matches > 1)
if (matchedText && *matchedText != converted)
return {};
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
matchedText = converted;
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
(!candidate || priority(i) < priority(candidate->target)))
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
}
return candidate;
+12 -6
View File
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
try
{
if (wp == 21) showSnippets(*app);
else if (wp == 22) showLogs(*app);
else showSettings(*app);
}
catch (const std::exception &e)
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
{
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
if (app->logWindow) DestroyWindow(app->logWindow);
PostQuitMessage(0);
return 0;
}
@@ -86,6 +88,7 @@ App::~App()
{
if (settingsWindow) DestroyWindow(settingsWindow);
if (snippetsWindow) DestroyWindow(snippetsWindow);
if (logWindow) DestroyWindow(logWindow);
if (window) DestroyWindow(window);
current = nullptr;
}
@@ -120,9 +123,9 @@ void App::setHotkeyCapture(HWND field)
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
}
}
void showLogs(App &app) { request(app, ConfigLogs); }
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
int runConfigurator(HINSTANCE instance, bool snippets)
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
{
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
@@ -140,7 +143,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
AllowSetForegroundWindow(pid);
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
return 0;
}
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
@@ -169,9 +172,10 @@ int runConfigurator(HINSTANCE instance, bool snippets)
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
if (snippets) showSnippets(app); else showSettings(app);
setLoggingEnabled(app.settings.writeLog);
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
MSG message;
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
{
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
{
@@ -180,6 +184,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
}
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
@@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
LocalFree(args);
result = ks::runConfigurator(instance, snippets);
result = ks::runConfigurator(instance, snippets, logs);
}
catch (const std::exception &e)
{
+7 -2
View File
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
auto expected = std::filesystem::path(executable()).parent_path() / filename;
return ok && _wcsicmp(path, expected.c_str()) == 0;
}
void launchConfiguration(App &app, bool snippets)
void launchConfiguration(App &app, bool snippets, bool logs)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
@@ -80,6 +80,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
case ConfigLogs:
PostMessageW(app.window, WM_HOTKEY, 22, 0);
break;
case ConfigClearLog:
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
throw std::runtime_error("Invalid log selection.");
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
break;
default: return FALSE;
}
ok = true;
+2 -2
View File
@@ -5,9 +5,9 @@ namespace ks
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6;
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
}
+11 -1
View File
@@ -261,7 +261,17 @@ Template parseTemplate(const std::wstring &text)
size_t cursor = std::wstring::npos;
for (size_t i = 0; i < text.size();)
{
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
if (text.compare(i, 9, L"{{enter}}") == 0)
{
t.text += L"{enter}";
i += 9;
}
else if (text.compare(i, 7, L"{enter}") == 0)
{
++t.enters;
i += 7;
}
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
{
t.text += L"{cursor}";
i += 10;
+3
View File
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <vector>
@@ -33,6 +34,7 @@ struct Template
{
std::wstring text, suffix;
int cursors = 0;
int enters = 0;
};
Template parseTemplate(const std::wstring &);
struct Hotkey
@@ -58,6 +60,7 @@ struct Settings
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
std::vector<std::wstring> autoNever;
std::map<std::wstring, std::vector<std::wstring>> autoWords;
std::vector<std::wstring> autoExcluded{
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "../vendor/json.hpp"
#include "platform.h"
#include <sstream>
namespace ks
{
using DiagnosticJson = nlohmann::json;
struct OperationLogRow
{
std::wstring time, process, command, source, target, outcome, details;
bool problem = true;
};
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
{
std::vector<OperationLogRow> rows;
std::wistringstream stream(contents);
std::wstring line;
OperationLogRow *legacy = nullptr;
while (std::getline(stream, line))
{
if (!line.empty() && line.back() == L'\r')
line.pop_back();
if (line.empty())
continue;
auto marker = line.find(L" Operation {");
if (marker != line.npos)
{
try
{
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
field("source"), field("target"), field("outcome")};
row.problem = j.value("problem", true);
row.details =
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
L"\r\n\r\nContext and stages:\r\n" + field("context");
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
if (at == 0 || row.details[at - 1] != L'\r')
row.details.insert(at++, 1, L'\r');
rows.push_back(std::move(row));
legacy = nullptr;
continue;
}
catch (...)
{
} // Preserve malformed and old records for diagnosis.
}
if (line.front() != L' ' || !legacy)
{
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
legacy = &rows.back();
}
else
legacy->details += L"\r\n" + line;
}
return rows;
}
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
const std::wstring &expected)
{
if (actual == expected)
return L"Paste verified";
if (actual == original)
return L"Original text remained";
return L"Readback differs";
}
} // 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;
}
}
+162
View File
@@ -0,0 +1,162 @@
#pragma once
#include "platform.h"
#include <uiautomation.h>
namespace ks
{
// Read-only UI Automation: never select, move the caret, or touch the clipboard.
class PasteProbe
{
IUIAutomation2 *automation{};
IUIAutomationElement *element{};
IUIAutomationTextPattern *pattern{};
public:
std::wstring status = L"not-requested";
PasteProbe(bool enabled, const std::wstring &original)
{
if (!enabled)
return;
status = L"accessibility-unavailable";
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
(void **)&automation)))
return;
automation->put_ConnectionTimeout(200);
automation->put_TransactionTimeout(200);
if (FAILED(automation->GetFocusedElement(&element)) || !element)
return;
BOOL password = TRUE;
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
{
status = L"protected-or-inaccessible-field";
return;
}
if (FAILED(element->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern,
(void **)&pattern)) ||
!pattern)
return;
auto selected = read(1, false);
auto normalize = [](std::wstring value)
{
for (size_t at = 0; (at = value.find(L"\r\n", at)) != value.npos;)
value.erase(at, 1);
return value;
};
if (!selected || normalize(*selected) != normalize(original))
{
if (selected)
status = L"initial-selection-mismatch";
if (pattern)
pattern->Release();
pattern = nullptr;
return;
}
status = L"ready";
}
PasteProbe(const PasteProbe &) = delete;
~PasteProbe()
{
if (pattern)
pattern->Release();
if (element)
element->Release();
if (automation)
automation->Release();
}
std::optional<std::wstring> read(int characters, bool allowCaret = true)
{
if (!pattern)
return {};
if (characters <= 0 || characters > 4096)
{
status = L"readback-size-limit";
return {};
}
IUIAutomationElement *focused{};
BOOL same = FALSE;
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
{
automation->CompareElements(element, focused, &same);
}
if (!same)
{
if (focused)
focused->Release();
status = L"focused-control-changed";
return {};
}
BOOL password = TRUE;
if (FAILED(focused->get_CurrentIsPassword(&password)) || password)
{
focused->Release();
status = L"protected-or-inaccessible-field";
return {};
}
// Native Edit accessibility proxies can retain a text snapshot in the
// pattern object. Obtain a fresh pattern after paste, not the pre-paste one.
pattern->Release();
pattern = nullptr;
HRESULT refreshed =
focused->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern, (void **)&pattern);
focused->Release();
if (FAILED(refreshed) || !pattern)
{
status = L"accessibility-unavailable";
return {};
}
IUIAutomationTextRangeArray *ranges{};
if (FAILED(pattern->GetSelection(&ranges)) || !ranges)
{
status = L"selection-unavailable";
return {};
}
int count{};
ranges->get_Length(&count);
IUIAutomationTextRange *range{};
if (count == 1)
ranges->GetElement(0, &range);
ranges->Release();
if (!range)
{
status = L"selection-ambiguous";
return {};
}
int difference{};
HRESULT hr = range->CompareEndpoints(TextPatternRangeEndpoint_Start, range,
TextPatternRangeEndpoint_End, &difference);
if (SUCCEEDED(hr) && difference == 0)
{
if (!allowCaret)
{
range->Release();
status = L"initial-selection-unavailable";
return {};
}
int moved{};
hr = range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start, TextUnit_Character, -characters,
&moved);
if (SUCCEEDED(hr) && moved != -characters)
hr = E_FAIL;
}
BSTR value{};
if (SUCCEEDED(hr))
hr = range->GetText(8193, &value);
range->Release();
if (FAILED(hr))
{
SysFreeString(value);
status = L"range-read-failed";
return {};
}
std::wstring text(value ? value : L"", SysStringLen(value));
SysFreeString(value);
if (text.size() > 8192)
{
status = L"readback-size-limit";
return {};
}
status = L"read-back-via-accessibility";
return text;
}
};
} // namespace ks
+21 -2
View File
@@ -1,6 +1,8 @@
#include "platform.h"
#include "../vendor/json.hpp"
#include <algorithm>
#include <fstream>
#include <iomanip>
#include <imm.h>
#include <map>
#include <mutex>
@@ -624,6 +626,22 @@ void diagnosticEvent(const std::wstring &message, bool problem)
if (!incident.empty())
log(incident);
}
void diagnosticOperation(const std::string &json)
{
std::lock_guard lock(logGate);
if (!loggingEnabled) return;
auto record = nlohmann::json::parse(json);
if (!record.value("problem", true))
{
// Keep successful operations only in the bounded, short-lived context.
// They are persisted with a subsequent failure, never as standalone rows.
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
nlohmann::json::error_handler_t::replace)), false, GetTickCount64());
return;
}
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
}
void log(const std::wstring &message)
{
std::lock_guard lock(logGate);
@@ -645,8 +663,9 @@ void log(const std::wstring &message)
std::ofstream f(path, std::ios::app);
SYSTEMTIME t;
GetLocalTime(&t);
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
<< t.wSecond << ' ' << utf8(message) << '\n';
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
}
catch (...)
{
+11
View File
@@ -8,6 +8,16 @@
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>;
struct Format
{
@@ -66,6 +76,7 @@ class DiagnosticBuffer
};
void setLoggingEnabled(bool enabled);
void diagnosticEvent(const std::wstring &message, bool problem = false);
void diagnosticOperation(const std::string &json);
std::wstring readDiagnosticLog(bool previous);
void clearDiagnosticLog(bool previous);
} // namespace ks
+76 -3
View File
@@ -178,6 +178,11 @@ struct Capture
COLORREF currentColor=RGB(225,42,50);
COLORREF customColors[16]{};
Mark stroke{};
bool strokeConstrained{};
size_t strokeAnchor{};
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
bool strokeAxisSettled{};
std::vector<POINT> strokeMotion;
POINT start{}, end{};
RECT selection{}, view{};
std::vector<Mark> marks;
@@ -250,7 +255,7 @@ void updateStatus(Capture &c)
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
else if(c.freehand) text=L"Freehand (F): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
if(text!=c.statusText) {
@@ -836,12 +841,12 @@ void edit(HWND w, Capture &c)
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
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);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
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); Ctrl+drag to draw over an object", 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)"};
static_assert(std::size(labels)==std::size(ToolIds));
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
@@ -1045,6 +1050,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
case WM_KEYDOWN:
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
c->strokeConstrained=true;
c->strokeAnchor=c->stroke.points.size()-1;
c->strokeAxis=0;
c->strokeAxisSettled=false;
c->strokeMotion={c->stroke.points.back()};
}
if (wp==VK_ESCAPE) {
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
return 0;
@@ -1100,6 +1112,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
}
return 0;
case WM_KEYUP:
if(wp==VK_SHIFT) c->strokeConstrained=false;
return 0;
case WM_LBUTTONDBLCLK:
if(c->editing && !c->dragging) {
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
@@ -1156,6 +1171,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
c->stroke.points.push_back(p);
c->strokeConstrained=false;
}
c->dragging=true; SetCapture(w);
}
@@ -1193,6 +1209,63 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
if(constrained) {
if(!c->strokeConstrained) {
c->strokeAnchor=c->stroke.points.size()-1;
c->strokeAxis=0;
c->strokeAxisSettled=false;
c->strokeMotion={c->stroke.points.back()};
}
auto anchor=c->stroke.points[c->strokeAnchor];
// Measure the actual mouse trajectory, not its snapped projection.
auto raw=c->end;
if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y)
c->strokeMotion.push_back(raw);
while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 ||
std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16))
c->strokeMotion.erase(c->strokeMotion.begin());
auto chooseAxis=[](POINT from,POINT to) {
LONG dx=to.x-from.x,dy=to.y-from.y;
constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees)
if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1;
if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2;
return (dx<0)==(dy<0)?3:4;
};
auto distance=[](int axis,POINT from,POINT to) {
double dx=to.x-from.x,dy=to.y-from.y;
return axis==1?std::abs(dy):axis==2?std::abs(dx):
std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0);
};
if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) {
c->strokeAxis=chooseAxis(anchor,c->end);
// A one-pixel first movement must not lock a diagonal gesture to an axis.
c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8;
}
else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) {
// Keep the completed segment and turn at its endpoint.
auto corner=c->stroke.points.back();
int nextAxis=chooseAxis(c->strokeMotion.front(),raw);
if(nextAxis!=c->strokeAxis) {
c->strokeAnchor=c->stroke.points.size()-1;
anchor=corner;
c->strokeAxis=nextAxis;
}
}
if(c->strokeAxis==1) c->end.y=anchor.y;
else if(c->strokeAxis==2) c->end.x=anchor.x;
else if(c->strokeAxis>=3) {
int sign=c->strokeAxis==3?1:-1;
LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0);
LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1);
LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y;
delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY));
c->end={anchor.x+delta,anchor.y+sign*delta};
}
// Only the current segment is a preview; earlier corners stay fixed.
c->stroke.points.resize(c->strokeAnchor+1);
}
c->strokeConstrained=constrained;
auto last=c->stroke.points.back();
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
c->stroke.to=c->end;
+7 -3
View File
@@ -1,13 +1,15 @@
#include "app.h"
#include "screenshot_notifications.h"
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";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
auto command=L"\""+path.wstring()+L"\" ";
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";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
CloseHandle(process.hThread);
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;
}
}
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)
{
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
+1
View File
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &);
Clip loadNotifiedScreenshot(const std::wstring &);
std::wstring cacheScreenshotForEditor(const Clip &);
}
+22
View File
@@ -92,6 +92,17 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions");
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 ||
s.maxImageMiB > 64)
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.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
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},
{"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}})
@@ -229,6 +244,13 @@ Bytes encodeSettings(const Settings &s)
j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
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},
{"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}})
+160 -276
View File
@@ -1,9 +1,12 @@
#include "../resources/resource.h"
#include "app.h"
#include "history_preview.h"
#include "ui_scroll.h"
#include "configuration.h"
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
#include "diagnostics.h"
#include <algorithm>
#include <commctrl.h>
#include <commdlg.h>
@@ -34,6 +37,8 @@ struct WindowLayout
bool scrolling{}, arranging{}, history{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
bool autoOptions{};
};
WindowLayout *windowLayoutState(HWND w)
{
@@ -69,8 +74,9 @@ void arrangeWindow(HWND w)
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
RECT client{}; GetClientRect(w, &client);
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;
bool expandable = layout->history || layout->stretch || layout->autoOptions;
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
int extraHeight = expandable ? 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)
@@ -80,6 +86,40 @@ void arrangeWindow(HWND w)
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
if (child == layout->stretch)
{
next.right += extraWidth;
next.bottom += extraHeight;
}
if (child == layout->details)
{
next.right += extraWidth;
OffsetRect(&next, 0, extraHeight);
}
if (child == layout->bottomLeft || child == layout->bottomRight)
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
if (layout->autoOptions)
{
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
{
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
int leftExtra = extraWidth * column / 3;
OffsetRect(&next, leftExtra, 0);
next.right += extraWidth * (column + 1) / 3 - leftExtra;
if (r.top == 328) next.bottom += extraHeight;
}
else if (r.top == 474 || r.top == 500)
{
bool rightColumn = r.left >= 290;
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
}
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
OffsetRect(&next, extraWidth, 0);
else if (r.right - r.left >= 500)
next.right += extraWidth;
}
RECT current{}; GetWindowRect(child, &current);
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(&current), 2);
if (!EqualRect(&current, &next)) moves.push_back({child, next});
@@ -200,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,
GetModuleHandleW(nullptr), nullptr);
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)
{
layout->controls.push_back({w, {x, y, x + width, y + height}});
@@ -279,7 +322,7 @@ std::wstring value(HWND w, int id)
text.resize(n);
return text;
}
void value(HWND w, int id, const std::wstring &text)
[[maybe_unused]] void value(HWND w, int id, const std::wstring &text)
{
SetDlgItemTextW(w, id, text.c_str());
}
@@ -330,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);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
SetWindowSubclass(w, layoutProc, 2, 0);
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
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,
SWP_NOACTIVATE | SWP_NOZORDER);
arrangeWindow(w);
SetWindowTextW(w, title);
SetWindowTextW(w, localBuildTitle(title).c_str());
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
@@ -440,7 +484,17 @@ struct HistoryView
std::map<std::wstring, std::string> richTexts;
HWND rich{};
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)
{
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
@@ -469,6 +523,7 @@ void search(HWND w, HistoryView *v)
}
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
{
if(v->scrolling) return;
const auto id = entry.id;
const bool image = entry.image;
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
@@ -652,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
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:
{
int id = LOWORD(wp);
@@ -664,6 +725,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
{
updateHistoryActions(w,v);
return 0;
}
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
@@ -709,10 +771,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (result->generation != v->generation)
return 0;
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);
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
int select = 0;
int restoreTop=0;
for (size_t i = 0; i < v->items.size(); ++i)
{
auto &e = v->items[i];
@@ -720,8 +785,11 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
if (e.id == old)
select = (int)i;
if(e.id==topId) restoreTop=(int)i;
}
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
updateHistoryActions(w,v);
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
return 0;
@@ -732,16 +800,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (result->generation != v->generation || !IsWindowVisible(w))
return 0;
v->pending.erase(result->id);
// At most ~3.3 MiB, irrespective of history length or source image size.
if (v->thumbnails.size() >= 32)
v->thumbnails.erase(v->thumbnails.begin());
if (v->richTexts.size() >= 32)
v->richTexts.erase(v->richTexts.begin());
auto list=GetDlgItem(w,102);
RECT client{}; GetClientRect(list,&client);
std::set<std::wstring> visible;
RECT changed{}; bool repaint=false;
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())
v->richTexts[result->id] = std::move(result->rtf);
else
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;
}
case WM_MEASUREITEM:
@@ -760,15 +837,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
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();
AppendMenuW(menu, MF_STRING, 110, L"Paste");
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
AppendMenuW(menu, MF_STRING, 113, L"Delete");
AppendMenuW(menu, MF_STRING, 114, 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)");
POINT p;
GetCursorPos(&p);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
DestroyMenu(menu);
if (cmd)
@@ -1009,6 +1100,15 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (checked(w, 403))
draft.autoLanguages.push_back(L"ru");
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.autoPrefixChars = value(w, 408);
draft.autoSuffixChars = value(w, 409);
@@ -1017,6 +1117,7 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
validateSettings(draft);
view->settings->draft.autoLanguages = draft.autoLanguages;
view->settings->draft.autoNever = draft.autoNever;
view->settings->draft.autoWords = draft.autoWords;
view->settings->draft.autoExcluded = draft.autoExcluded;
view->settings->draft.autoMaxRank = draft.autoMaxRank;
view->settings->draft.autoMinLength = draft.autoMinLength;
@@ -1054,6 +1155,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
{
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
560, 680);
windowLayoutState(w)->autoOptions = true;
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
@@ -1063,13 +1165,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
for (auto &lang : v->draft.autoLanguages)
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
edit(w, 404, 18, 122, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
edit(w, 404, 18, 122, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 404, commaList(v->draft.autoExcluded));
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525);
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
label(w, L"Never correct these words (comma-separated)", 18, 184, 525);
edit(w, 405, 18, 212, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 405, commaList(v->draft.autoNever));
label(w, L"Corrects after Space and a short pause.", 18, 344, 525);
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 525);
label(w, L"My dictionary: correct words, separated by commas or new lines", 18, 276, 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);
edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
value(w, 406, std::to_wstring(v->draft.autoMaxRank));
@@ -1087,6 +1196,11 @@ void showAutoOptions(HWND parent, SettingsView *v)
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
button(w, L"OK", IDOK, 326, 632, 100);
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
RECT initial{};
GetWindowRect(w, &initial);
initial.right += scale(w, 200);
initial.bottom += scale(w, 140);
fitToMonitor(w, initial);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetForegroundWindow(w);
@@ -1320,6 +1434,14 @@ struct SnippetView
std::vector<Snippet> snippets;
int selection = -1;
};
void updateSnippetEditor(HWND w, SnippetView *v)
{
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
value(w, 303, selected ? v->snippets[v->selection].text : L"");
for (int id : {302, 303, 311, 312, 313})
EnableWindow(GetDlgItem(w, id), selected);
}
void fillSnippets(HWND w, SnippetView *v)
{
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
@@ -1327,6 +1449,7 @@ void fillSnippets(HWND w, SnippetView *v)
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
if (v->selection >= 0)
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
updateSnippetEditor(w, v);
}
void collectSnippet(HWND w, SnippetView *v)
{
@@ -1363,11 +1486,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
collectSnippet(w, v);
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
if (v->selection >= 0)
{
value(w, 302, v->snippets[v->selection].trigger);
value(w, 303, v->snippets[v->selection].text);
}
updateSnippetEditor(w, v);
return 0;
}
if (id == 310)
@@ -1376,8 +1495,6 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
v->snippets.push_back({L";new", L""});
v->selection = (int)v->snippets.size() - 1;
fillSnippets(w, v);
value(w, 302, L";new");
value(w, 303, L"");
SetFocus(GetDlgItem(w, 302));
return 0;
}
@@ -1388,14 +1505,14 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
v->snippets.erase(v->snippets.begin() + v->selection);
v->selection = -1;
fillSnippets(w, v);
value(w, 302, L"");
value(w, 303, L"");
SetFocus(GetDlgItem(w, 301));
}
return 0;
}
if (id == 312)
if (id == 312 || id == 313)
{
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}");
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
return 0;
}
if (id == IDOK)
@@ -1513,251 +1630,11 @@ void showHistory(App &app, bool visible)
SetFocus(GetDlgItem(app.historyWindow, 101));
refreshHistory(app);
}
namespace
{
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606;
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
// Header notifications go to the ListView, not its top-level parent.
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
{
auto *draw = (NMCUSTOMDRAW *)lp;
if (draw->dwDrawStage == CDDS_PREPAINT)
{
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
return CDRF_NOTIFYITEMDRAW;
}
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
{
int saved = SaveDC(draw->hdc);
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
RECT edge = draw->rc;
edge.top = edge.bottom - 1;
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
edge = draw->rc;
edge.left = edge.right - 1;
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
wchar_t title[128]{};
HDITEMW item{};
item.mask = HDI_TEXT;
item.pszText = title;
item.cchTextMax = 128;
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
if (headerFont)
SelectObject(draw->hdc, headerFont);
SetBkMode(draw->hdc, TRANSPARENT);
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
RECT text = draw->rc;
InflateRect(&text, -scale(w, 6), 0);
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
RestoreDC(draw->hdc, saved);
return CDRF_SKIPDEFAULT;
}
}
if (msg == WM_NCDESTROY)
RemoveWindowSubclass(w, logListProc, 1);
return DefSubclassProc(w, msg, wp, lp);
}
void copyLogRows(HWND w, App &app)
{
HWND list = GetDlgItem(w, LogRows);
std::wstring copied;
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
{
for (int column = 0; column < 4; ++column)
{
std::vector<wchar_t> text(32768);
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
if (column)
copied += L'\t';
copied += text.data();
}
copied += L"\r\n";
}
if (!copied.empty())
app.ownSequence = writeClipboard(app.window, textClip(copied));
}
void refreshLogView(HWND w, App &app)
{
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
std::wstring contents;
try
{
contents = readDiagnosticLog(previous);
}
catch (const std::exception &e)
{
contents = L"Could not read the log: " + wide(e.what());
}
HWND list = GetDlgItem(w, LogRows);
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
ListView_DeleteAllItems(list);
std::wistringstream lines(contents);
std::wstring line, timestamp;
int row = 0;
while (std::getline(lines, line))
{
if (!line.empty() && line.back() == L'\r')
line.pop_back();
if (line.empty())
continue;
auto firstSpace = line.find(L' ');
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
{
timestamp = line.substr(0, secondSpace);
line.erase(0, secondSpace + 1);
}
auto id = line.find(L" id=");
std::wstring attempt;
if (id != line.npos)
{
auto end = line.find(L' ', id + 4);
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
}
std::wstring event = line;
if (event.starts_with(L" ageMs="))
{
auto end = event.find(L' ', 8);
if (end != event.npos)
event.erase(0, end + 1);
}
auto equals = event.find(L'=');
if (equals != event.npos)
{
auto key = event.rfind(L' ', equals);
if (key != event.npos)
event.resize(key);
}
LVITEMW item{};
item.mask = LVIF_TEXT;
item.iItem = row;
item.pszText = timestamp.data();
ListView_InsertItem(list, &item);
ListView_SetItemText(list, row, 1, attempt.data());
ListView_SetItemText(list, row, 2, event.data());
ListView_SetItemText(list, row, 3, line.data());
++row;
}
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
InvalidateRect(list, nullptr, TRUE);
if (row)
ListView_EnsureVisible(list, row - 1, FALSE);
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
: L"Logging disabled. Showing previously saved entries.");
}
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (msg == WM_NCCREATE)
{
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
return TRUE;
}
if (!app)
return DefWindowProcW(w, msg, wp, lp);
switch (msg)
{
case WM_COMMAND:
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
{
refreshLogView(w, *app);
return 0;
}
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
{
try { copyLogRows(w, *app); }
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
{
try
{
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
refreshLogView(w, *app);
}
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
break;
[[fallthrough]];
case WM_CLOSE:
DestroyWindow(w);
return 0;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
{
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
if (GetKeyState(VK_CONTROL) & 0x8000)
{
if (key == 'A')
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
if (key == 'C')
{
try { copyLogRows(w, *app); }
catch (const std::exception &e) { error(w, e); }
}
}
}
return 0;
case WM_DPICHANGED:
resizeForDpi(w, wp, lp);
return 0;
case WM_NCDESTROY:
app->logWindow = nullptr;
disposeFont(w);
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
}
void showLogs(App &app)
{
if (app.logWindow)
{
showRequestedWindow(app.logWindow);
activateWindow(app.logWindow);
return;
}
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
app.logWindow = w;
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
SendMessageW(choice, CB_SETCURSEL, 0, 0);
button(w, L"Refresh", LogRefresh, 330, 14, 110);
button(w, L"Copy selected", LogCopy, 454, 14, 140);
button(w, L"Clear log", LogClear, 608, 14, 110);
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&common);
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
LogRows, 16, 82, 868, 454);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
SetWindowSubclass(list, logListProc, 1, 0);
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
const int widths[] = {155, 65, 195, 900};
for (int i = 0; i < 4; ++i)
{
LVCOLUMNW column{};
column.mask = LVCF_TEXT | LVCF_WIDTH;
column.pszText = const_cast<wchar_t *>(titles[i]);
column.cx = scale(w, widths[i]);
ListView_InsertColumn(list, i, &column);
}
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
button(w, L"Close", IDCANCEL, 774, 548, 110);
refreshLogView(w, app);
showRequestedWindow(w);
activateWindow(w);
}
#endif
#ifndef KS_RESIDENT_APP
#include "ui_log.inl"
#else
void showLogs(App &app) { launchConfiguration(app, false, true); }
#endif
#ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible)
@@ -1844,8 +1721,9 @@ void showSnippets(App &app)
edit(w, 302, 236, 44, 488);
label(w, L"Replacement text", 236, 82, 480);
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488);
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
button(w, L"Insert cursor marker", 312, 236, 430, 190);
button(w, L"Insert Enter marker", 313, 436, 430, 190);
button(w, L"Save", IDOK, 510, 478, 100);
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
@@ -1875,11 +1753,17 @@ void showEntry(App &app, const std::wstring &id)
try
{
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();
if (text.empty())
{
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"Clipboard item", MB_OK);
return;
+357
View File
@@ -0,0 +1,357 @@
// Included inside namespace ks by ui.cpp; shares the native control/layout helpers.
namespace
{
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606, LogProgram = 607, LogCommand = 608, LogResult = 609, LogProblems = 610,
LogDetails = 611, LogReset = 612;
struct LogViewState
{
std::vector<OperationLogRow> rows;
std::vector<size_t> visible;
int sortColumn = 0;
bool descending = true, rendering = false;
};
LogViewState *logState(HWND w)
{
return reinterpret_cast<LogViewState *>(GetPropW(w, L"LogViewState"));
}
const std::wstring &logCell(const OperationLogRow &row, int column)
{
switch (column)
{
case 0:
return row.time;
case 1:
return row.process;
case 2:
return row.command;
case 3:
return row.source;
case 4:
return row.target;
default:
return row.outcome;
}
}
void showLogDetails(HWND w)
{
auto *state = logState(w);
if (!state || state->rendering)
return;
int selected = ListView_GetNextItem(GetDlgItem(w, LogRows), -1, LVNI_SELECTED);
value(w, LogDetails,
selected >= 0 && (size_t)selected < state->visible.size()
? state->rows[state->visible[selected]].details
: L"Select an operation to inspect its text and stages.");
}
void renderLogRows(HWND w, App &app)
{
auto *state = logState(w);
if (!state)
return;
state->rendering = true;
state->visible.clear();
auto matches = [&](int id, const std::wstring &field)
{ return SendDlgItemMessageW(w, id, CB_GETCURSEL, 0, 0) <= 0 || value(w, id) == field; };
for (size_t i = 0; i < state->rows.size(); ++i)
{
const auto &row = state->rows[i];
if (matches(LogProgram, row.process) && matches(LogCommand, row.command) &&
matches(LogResult, row.outcome) && (!checked(w, LogProblems) || row.problem))
state->visible.push_back(i);
}
std::stable_sort(state->visible.begin(), state->visible.end(),
[&](size_t a, size_t b)
{
const auto &left = logCell(state->rows[a], state->sortColumn);
const auto &right = logCell(state->rows[b], state->sortColumn);
if (left == right)
return state->descending ? a > b : a < b;
int comparison = CompareStringOrdinal(left.c_str(), -1, right.c_str(), -1, TRUE);
return state->descending ? comparison == CSTR_GREATER_THAN
: comparison == CSTR_LESS_THAN;
});
HWND list = GetDlgItem(w, LogRows);
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
ListView_DeleteAllItems(list);
for (size_t i = 0; i < state->visible.size(); ++i)
{
const auto &row = state->rows[state->visible[i]];
LVITEMW item{};
item.mask = LVIF_TEXT;
item.iItem = (int)i;
item.pszText = const_cast<wchar_t *>(row.time.c_str());
ListView_InsertItem(list, &item);
for (int column = 1; column < 6; ++column)
ListView_SetItemText(list, (int)i, column, const_cast<wchar_t *>(logCell(row, column).c_str()));
}
HWND header = ListView_GetHeader(list);
for (int i = 0; i < 6; ++i)
{
HDITEMW item{};
item.mask = HDI_FORMAT;
Header_GetItem(header, i, &item);
item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN);
if (i == state->sortColumn)
item.fmt |= state->descending ? HDF_SORTDOWN : HDF_SORTUP;
Header_SetItem(header, i, &item);
}
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
InvalidateRect(list, nullptr, TRUE);
state->rendering = false;
value(w, LogStatus,
std::to_wstring(state->visible.size()) + L" of " + std::to_wstring(state->rows.size()) +
L" operations / events. " +
(app.settings.writeLog ? L"Diagnostic logging enabled."
: L"Logging disabled; saved entries only."));
if (!state->visible.empty())
ListView_SetItemState(list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
showLogDetails(w);
if (state->visible.empty())
value(w, LogDetails,
state->rows.empty()
? L"No recorded operations. Use a conversion shortcut with logging enabled, then refresh."
: L"No operations match these filters. Reset filters to show all entries.");
}
void fillLogFilter(HWND w, int id, const std::set<std::wstring> &choices)
{
auto previous = value(w, id);
HWND combo = GetDlgItem(w, id);
SendMessageW(combo, CB_RESETCONTENT, 0, 0);
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"All");
int selected = 0, index = 1;
for (const auto &choice : choices)
{
if (choice.empty())
continue;
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)choice.c_str());
if (choice == previous)
selected = index;
++index;
}
SendMessageW(combo, CB_SETCURSEL, selected, 0);
}
void refreshLogView(HWND w, App &app)
{
auto *state = logState(w);
if (!state)
return;
try
{
auto contents = readDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
state->rows = parseOperationLog(contents);
if (contents.starts_with(L"No log yet.") || contents.starts_with(L"No previous log") ||
contents == L"The log is empty.")
state->rows.clear();
std::set<std::wstring> programs, commands, results;
for (const auto &row : state->rows)
{
programs.insert(row.process);
commands.insert(row.command);
results.insert(row.outcome);
}
fillLogFilter(w, LogProgram, programs);
fillLogFilter(w, LogCommand, commands);
fillLogFilter(w, LogResult, results);
renderLogRows(w, app);
}
catch (const std::exception &e)
{
value(w, LogStatus, L"Could not read the log: " + wide(e.what()));
}
}
void copyLogRows(HWND w, App &app)
{
auto *state = logState(w);
if (!state)
return;
std::wstring copied;
HWND list = GetDlgItem(w, LogRows);
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
{
if ((size_t)row >= state->visible.size())
continue;
const auto &entry = state->rows[state->visible[row]];
copied += entry.time + L"\t" + entry.process + L"\t" + entry.command + L"\t" + entry.source +
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
}
if (!copied.empty())
{
auto clip = textClip(copied);
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
app.ownSequence = writeClipboard(app.window, clip);
}
}
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (msg == WM_NCCREATE)
{
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
SetPropW(w, L"LogViewState", new LogViewState);
return TRUE;
}
if (!app)
return DefWindowProcW(w, msg, wp, lp);
switch (msg)
{
case WM_COMMAND:
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
{
refreshLogView(w, *app);
return 0;
}
if ((LOWORD(wp) >= LogProgram && LOWORD(wp) <= LogResult && HIWORD(wp) == CBN_SELCHANGE) ||
(LOWORD(wp) == LogProblems && HIWORD(wp) == BN_CLICKED))
{
renderLogRows(w, *app);
return 0;
}
if (LOWORD(wp) == LogReset && HIWORD(wp) == BN_CLICKED)
{
for (int id : {LogProgram, LogCommand, LogResult})
SendDlgItemMessageW(w, id, CB_SETCURSEL, 0, 0);
CheckDlgButton(w, LogProblems, BST_UNCHECKED);
renderLogRows(w, *app);
return 0;
}
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
{
try
{
copyLogRows(w, *app);
}
catch (const std::exception &e)
{
error(w, e);
}
return 0;
}
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
{
try
{
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
refreshLogView(w, *app);
}
catch (const std::exception &e)
{
error(w, e);
}
return 0;
}
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
break;
[[fallthrough]];
case WM_CLOSE:
DestroyWindow(w);
return 0;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom != LogRows)
break;
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
showLogDetails(w);
if (((NMHDR *)lp)->code == LVN_COLUMNCLICK)
{
auto *state = logState(w);
int column = ((NMLISTVIEW *)lp)->iSubItem;
state->descending = state->sortColumn == column ? !state->descending : false;
state->sortColumn = column;
renderLogRows(w, *app);
}
if (((NMHDR *)lp)->code == LVN_KEYDOWN)
{
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
if (GetKeyState(VK_CONTROL) & 0x8000)
{
if (key == 'A')
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
if (key == 'C')
{
try
{
copyLogRows(w, *app);
}
catch (const std::exception &e)
{
error(w, e);
}
}
}
}
return 0;
case WM_DPICHANGED:
resizeForDpi(w, wp, lp);
return 0;
case WM_NCDESTROY:
delete logState(w);
RemovePropW(w, L"LogViewState");
app->logWindow = nullptr;
disposeFont(w);
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
} // namespace
void showLogs(App &app)
{
if (app.logWindow)
{
showRequestedWindow(app.logWindow);
activateWindow(app.logWindow);
return;
}
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher diagnostic operations", logProc, 1100, 760);
app.logWindow = w;
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 280, 140);
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
SendMessageW(choice, CB_SETCURSEL, 0, 0);
button(w, L"Refresh", LogRefresh, 310, 14, 100);
button(w, L"Copy selected", LogCopy, 424, 14, 140);
button(w, L"Clear log", LogClear, 578, 14, 100);
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
label(w, L"Program", 16, 78, 170);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
label(w, L"Command", 206, 78, 200);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
label(w, L"Result", 426, 78, 300);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
button(w, L"Reset filters", LogReset, 938, 98, 146);
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&common);
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
LogRows, 16, 142, 1068, 260);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER |
LVS_EX_INFOTIP);
const wchar_t *titles[] = {L"Time", L"Program", L"Command",
L"Source layout", L"Target layout", L"Result"};
const int widths[] = {165, 145, 150, 130, 130, 320};
for (int i = 0; i < 6; ++i)
{
LVCOLUMNW column{};
column.mask = LVCF_TEXT | LVCF_WIDTH;
column.pszText = const_cast<wchar_t *>(titles[i]);
column.cx = scale(w, widths[i]);
ListView_InsertColumn(list, i, &column);
}
auto details = edit(w, LogDetails, 16, 416, 1068, 276,
ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL | WS_BORDER);
auto hint = control(
w, L"STATIC",
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
16, 712, 860, 22);
button(w, L"Close", IDCANCEL, 974, 708, 110);
auto *layout = windowLayoutState(w);
layout->stretch = list;
layout->details = details;
layout->bottomLeft = hint;
layout->bottomRight = GetDlgItem(w, IDCANCEL);
arrangeWindow(w);
refreshLogView(w, app);
showRequestedWindow(w);
activateWindow(w);
}
+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;
}
}
+5 -4
View File
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
return out + L'"';
}
}
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
{
UpdateResult result;
GUID guid{};
@@ -56,6 +56,7 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
if (showProgress) command += L" -ShowProgress";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
@@ -94,10 +95,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
throw;
}
}
void confirmUpdatedStartup(const std::wstring &eventName)
bool confirmUpdatedStartup(const std::wstring &eventName)
{
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
if (event.value) SetEvent(event.value);
return event.value && SetEvent(event.value);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.9";
inline constexpr wchar_t AppVersion[] = L"0.6.20";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
@@ -13,6 +13,6 @@ struct UpdateResult
std::wstring state, version, error;
bool interactive{}, install{};
};
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
void confirmUpdatedStartup(const std::wstring &eventName);
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
bool confirmUpdatedStartup(const std::wstring &eventName);
}
+52 -2
View File
@@ -1,4 +1,4 @@
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
$ErrorActionPreference = 'Stop'
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
throw 'Close KSwitcher and its configurator before this isolated process test.'
@@ -18,6 +18,7 @@ public static class ConfigurationTestUi {
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
@@ -38,7 +39,7 @@ $checks=0
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
function Window($class) {
for($i=0;$i -lt 100;$i++) {
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
$window=[ConfigurationTestUi]::FindWindow($class,$title)
if($window -ne [IntPtr]::Zero){return $window}
Start-Sleep -Milliseconds 50
@@ -56,6 +57,24 @@ function Launch($file,$argument) {
function Click($window,$id) {
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
}
function TestLogs {
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
$config=Launch 'KSwitcher.Config.exe' '--logs'
$logs=Window 'KSwitcher.Logs'
[uint32]$logOwner=0
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
$second=Launch 'KSwitcher.Config.exe' '--logs'
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
Click $logs 603
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
Click $logs 606
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
Click $logs 2
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
}
try {
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
@@ -67,6 +86,13 @@ try {
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
$resident=Launch 'KSwitcher.exe' ''
$main=Window 'KSwitcher.Main'
if ($LogsOnly) {
TestLogs
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
"Passed $checks log process checks"
return
}
$config=Launch 'KSwitcher.Config.exe' ''
$settings=Window 'KSwitcher.Settings'
[uint32]$ownerId=0
@@ -102,6 +128,29 @@ try {
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
$config=Launch 'KSwitcher.Config.exe' '--snippets'
$snippets=Window 'KSwitcher.Snippets'
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
foreach($id in @(302,303,311,312,313)) {
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
}
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
foreach($id in @(302,303,311,312,313)) {
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
}
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
foreach($id in @(302,303,311,312,313)) {
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
}
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
$snippetText=[Text.StringBuilder]::new(128)
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
$second=Launch 'KSwitcher.Config.exe' ''
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
$settings=Window 'KSwitcher.Settings'
@@ -110,6 +159,7 @@ try {
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
Click $snippets 2
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
TestLogs
$config=Launch 'KSwitcher.Config.exe' ''
$null=Window 'KSwitcher.Settings'
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
+66
View File
@@ -1,4 +1,5 @@
#include "app.h"
#include "ui_scroll.h"
#include "ui_geometry.h"
#include "screenshot.h"
#include <commctrl.h>
@@ -55,6 +56,14 @@ int main(int argc, char **argv) {
// 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);
require(app.window!=nullptr,"message-only test window");
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
"successful startup signals the updater before reporting completion");
CloseHandle(started);
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
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");
@@ -586,6 +595,58 @@ int main(int argc, char **argv) {
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");
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);
{
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
"auto-correction dialog supports resizing");
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
GetWindowRect(autoOptions,&original);
// Use a client size above the scrolling baseline at every DPI.
UINT dpi=GetDpiForWindow(autoOptions);
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
for(int i=0;i<3;++i) {
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
"dictionary columns share extra width and receive extra height");
}
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
"auto-correction buttons follow bottom-right corner");
require(after[0].right<after[1].left && after[1].right<after[2].left &&
after[2].bottom<buttonAfter.top,
"resized dictionary fields and footer do not overlap");
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
}
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");
ks::showHistory(app,false);
auto history=app.historyWindow;
@@ -668,16 +729,21 @@ int main(int argc, char **argv) {
controlMoves=0;
ValidateRect(settings,nullptr);
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");
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");
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");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,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);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,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);
+386 -7
View File
@@ -1,16 +1,86 @@
#include "app.h"
#include "diagnostics.h"
#include <algorithm>
#include <iostream>
#include <fstream>
#include <commctrl.h>
namespace ks
{
struct InputRegressionAccess
{
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
return app.typingTarget != nullptr;
}
};
}
namespace
{
ks::App *app{};
HWND host{}, field{};
ks::Clip saved;
bool captured = false, failed = false;
bool conversionOnly = false;
int pasteBehavior = 0;
int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0;
bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
// This bare EDIT control is our test fixture, not a full editor with
// clipboard retries. Keep clipboard access on its owning UI thread while
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
// make the fixture silently discard the operation under test.
struct ClipboardAccess
{
bool opened = false;
~ClipboardAccess() { if (opened) CloseClipboard(); }
} access;
if (message == WM_CHAR && (wp == 3 || wp == 22))
{
auto deadline = GetTickCount64() + 1000;
do
{
if (OpenClipboard(w)) { access.opened = true; break; }
Sleep(10);
} while (GetTickCount64() < deadline);
if (!access.opened)
{
clipboardAccessTimedOut = true;
return 0;
}
}
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
{
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
if (message == WM_CHAR)
{
++copyRequests;
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
// Reproduce that outcome deterministically, without depending on
// the timing of the Windows clipboard-history service.
if (copyFailuresRemaining > 0)
{
--copyFailuresRemaining;
return 0;
}
}
}
if (message == WM_PASTE && pasteBehavior)
{
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
return 0;
}
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
return DefSubclassProc(w, message, wp, lp);
}
int stage = 0, ru = -1, en = -1;
size_t punctuationCase = 0;
bool typeDuringCorrection = false, typedDuringCorrection = false;
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"#дома%; "}};
@@ -24,8 +94,17 @@ std::string diagnosticLog()
size_t diagnosticStart{};
void require(bool ok, const char *name)
{
if (!ok)
if (!ok || clipboardAccessTimedOut)
{
wchar_t observed[2048]{};
GetWindowTextW(field, observed, 2048);
std::cerr << "Editor at failure: " << ks::utf8(observed)
<< "; foregroundSame=" << (GetForegroundWindow() == host)
<< "; inputVersion=" << app->inputVersion.load() << '\n';
if (clipboardAccessTimedOut)
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
throw std::runtime_error(name);
}
std::cout << "PASS " << name << '\n';
}
std::wstring text()
@@ -75,12 +154,34 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0;
if (msg == WM_TIMER)
{
// Input tests must not start a network update check. Its close veto can
// otherwise leave a completed test running after the editor is gone.
if (app->window) KillTimer(app->window, 5);
try
{
if (GetTickCount64() > deadline)
throw std::runtime_error("Native integration timed out");
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;
}
switch (stage++)
{
case 0:
@@ -133,12 +234,20 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
"busy conversion attempt is logged");
require(diagnostics.find("repeat=3") != std::string::npos,
"three repeated conversion attempts are logged");
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
require(diagnostics.find("\"outcome\":\"recent-verification-mismatch\"") != std::string::npos,
"failed recent-word verification has explicit outcome");
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
"conversion diagnostics contain actual text and physical input for reproduction");
}
if (conversionOnly)
{
pasteBehavior = 1;
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
stage = 40;
break;
}
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
prepare(L"prefix ;addr", false);
record(L";addr");
@@ -196,6 +305,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
SetWindowSubclass(field, observeEnter, 1, 0);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
@@ -207,6 +317,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
nullptr, GetModuleHandleW(nullptr), nullptr);
SetWindowSubclass(field, observeEnter, 1, 0);
prepare(L"(ljvf ", false);
record(L"(ljvf ");
app->transform(ks::AutoCorrect);
@@ -242,8 +353,217 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
case 17:
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
stage = 20;
break;
case 40:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
"blocked paste records original text remaining in the actual editor");
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
pasteBehavior = 2;
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
break;
}
case 41:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
"unexpected editor content records readback mismatch");
pasteBehavior = 0;
prepare(L"привет", true);
app->transform(ks::Direct + ru);
break;
}
case 42:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
"manual unchanged conversion is recorded as a problem");
diagnosticStart = diagnosticLog().size();
app->settings.writeLog = false;
ks::setLoggingEnabled(false);
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
break;
}
case 43:
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
"disabled diagnostics preserves conversion and writes no records");
ks::setLoggingEnabled(true);
finish();
break;
case 30:
case 32:
case 34:
case 37:
// Earlier password/word tests replace the original multiline
// editor with a single-line EDIT. Restore the snippet fixture:
// a single-line control cannot insert the tested Enter action.
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
{
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
GetModuleHandleW(nullptr), nullptr);
SetWindowSubclass(field, observeEnter, 1, 0);
}
if (!captured)
{
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
}
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
app->settings.autoCorrect = stage == 35;
app->settings.autoLanguages = {L"en", L"ru"};
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
app->settings.switchLayout = false;
app->autoCorrector = ks::AutoCorrector{};
app->autoPredictor = ks::AutoCorrector{};
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
enterPresses = 0;
copyRequests = 0;
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
temporaryClipboardExcluded = true;
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"prefix ", false);
{
std::vector<INPUT> inputs;
const std::vector<WORD> keys = stage == 35
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
for (WORD key : keys)
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
{
auto input = ks::keyboardInput(key, flags);
input.ki.dwExtraInfo = 123;
inputs.push_back(input);
}
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
"snippet trigger and following text submitted without a pause");
}
break;
case 31:
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
stage = 32;
break;
case 33:
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
require(copyRequests == 2 && copyFailuresRemaining == 0,
"snippet retries a silently dropped copy without duplicating input");
require(temporaryClipboardExcluded,
"temporary snippet clipboard contents are excluded from Windows history");
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
stage = 34;
break;
case 35:
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
{
std::vector<INPUT> inputs;
for (WORD key : std::wstring(L"GHBDTN "))
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
{
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
input.ki.dwExtraInfo = 123;
inputs.push_back(input);
}
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
"normal typing submitted after snippet completes");
}
break;
case 36:
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
stage = 37;
break;
case 38:
require(copyRequests == 3, "copy retries are bounded to three attempts");
require(text() == L"prefix ;r x" && enterPresses == 0,
"exhausted copy retries preserve trigger and following input without Enter");
require(ks::readClipboard(app->window).text() == sentinel,
"exhausted copy retries restore the clipboard");
copyFailuresRemaining = 0;
finish();
break;
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");
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"", false);
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
{
std::vector<INPUT> inputs;
for (WORD key : std::wstring(L"GHBDTN F"))
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
{
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
input.ki.dwExtraInfo = 123;
inputs.push_back(input);
}
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
"continuous typing submitted in a single batch without a pause after Space");
}
stage = 21;
break;
case 21:
{
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
return ks::layoutLanguage(layout) == L"uk";
});
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
app->settings.autoLanguages = {L"en", L"uk"};
app->settings.cycle = {app->layouts[en].id, uk->id};
app->settings.autoWords[L"uk"] = {L"робить"};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"hj,bnm ", false);
record(L"hj,bnm ");
app->transform(ks::AutoCorrect);
stage = 22;
break;
}
case 22:
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
stage = 30;
break;
}
}
catch (const std::exception &e)
@@ -265,6 +585,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} // namespace
int main(int argc, char **argv)
{
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
@@ -280,9 +601,22 @@ int main(int argc, char **argv)
ks::showLogs(instance);
HWND viewer = instance.logWindow;
HWND rows = GetDlgItem(viewer, 602);
if (argc > 2 && std::string(argv[2]) == "--preview")
{
MSG message{};
while (IsWindow(viewer) && GetMessageW(&message, nullptr, 0, 0) > 0)
{
TranslateMessage(&message); DispatchMessageW(&message);
}
CoUninitialize();
return 0;
}
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
"log viewer uses report ListView");
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
require(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable columns");
require(GetDlgItem(viewer, 607) && GetDlgItem(viewer, 608) && GetDlgItem(viewer, 609) &&
GetDlgItem(viewer, 610) && GetDlgItem(viewer, 611),
"log viewer exposes program, command, result, problems filter and detail panel");
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
SetFocus(rows);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
@@ -292,10 +626,11 @@ int main(int argc, char **argv)
ks::showLogs(instance);
require(instance.logWindow == viewer, "opening logs reuses the existing window");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
require(IsWindow(viewer), "refresh keeps log viewer open");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
"previous log or explicit empty state is displayed");
if (argc > 2 && std::string(argv[2]) == "--clear-test")
{
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
@@ -320,7 +655,46 @@ int main(int argc, char **argv)
require(newLog.find(L"new-problem") != std::wstring::npos &&
newLog.find(L"buffered-before-clear") == std::wstring::npos,
"logging resumes after clear without stale context");
ks::DiagnosticJson operation{{"attempt", "1"}, {"process", "alpha.exe"},
{"command", "Convert / cycle"}, {"source", "English"}, {"target", "Ukrainian"},
{"outcome", "Paste verified"}, {"problem", false}, {"original", "ghbdsn"},
{"expected", ks::utf8(L"привіт")}, {"actual", ks::utf8(L"привіт")}};
ks::clearDiagnosticLog(false);
const auto successfulOperation = operation;
for (int attempt = 0; attempt < 1000; ++attempt)
ks::diagnosticOperation(operation.dump());
require(!std::filesystem::exists(logRoot / L"kswitcher.log"),
"one thousand successful operations create no log file");
operation["attempt"] = "2"; operation["process"] = "beta.exe";
operation["outcome"] = "Original text remained"; operation["problem"] = true;
operation["actual"] = operation["original"];
ks::diagnosticOperation(operation.dump());
auto problems = ks::parseOperationLog(ks::readDiagnosticLog(false));
require(problems.size() == 1 && problems[0].process == L"beta.exe" &&
problems[0].details.find(L"Completed operation") != std::wstring::npos &&
problems[0].details.find(L"Paste verified") != std::wstring::npos,
"failure writes one row with bounded preceding successful context");
// Pre-existing successful records remain viewable and filterable.
ks::log(L"Operation " + ks::wide(successfulOperation.dump()));
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
require(ListView_GetItemCount(rows) == 2, "one visible row per recorded operation");
CheckDlgButton(viewer, 610, BST_CHECKED);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(610, BN_CLICKED), 0);
require(ListView_GetItemCount(rows) == 1, "only problems hides verified operations");
SendDlgItemMessageW(viewer, 607, CB_SETCURSEL, 1, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(607, CBN_SELCHANGE), 0);
require(ListView_GetItemCount(rows) == 0, "program filter combines with problems filter");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(612, BN_CLICKED), 0);
require(ListView_GetItemCount(rows) == 2, "reset restores all operation rows");
NMLISTVIEW sort{}; sort.hdr = {rows, 602, LVN_COLUMNCLICK}; sort.iSubItem = 1;
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&sort);
wchar_t firstProgram[64]{};
ListView_GetItemText(rows, 0, 1, firstProgram, 64);
require(std::wstring(firstProgram) == L"alpha.exe", "clicking program column sorts rows");
require(GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0, "selected operation populates detail panel");
}
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
auto savedClipboard = ks::readClipboard(viewer);
instance.window = viewer;
try
@@ -366,6 +740,11 @@ int main(int argc, char **argv)
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000;
SetWindowSubclass(field, observeEnter, 1, 0);
if (argc > 1 && std::string(argv[1]) == "--typing-only")
stage = 20;
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
stage = 30;
SetTimer(host, 1, 100, nullptr);
app->execute();
}
+100 -1
View File
@@ -24,6 +24,105 @@ int main()
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
"new screenshot editor starts with the pencil tool");
{
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
auto mouse=[&](UINT message,POINT p,bool shift=false) {
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
};
mouse(WM_LBUTTONDOWN,{40,40},true);
mouse(WM_MOUSEMOVE,{100,50},true);
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
mouse(WM_MOUSEMOVE,{100,90},true);
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
"50 pixel deviation keeps the current axis");
mouse(WM_MOUSEMOVE,{100,91},true);
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
"51 pixel deviation turns vertically and preserves the horizontal segment");
mouse(WM_MOUSEMOVE,{70,140});
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
"releasing Shift resumes freehand in the same stroke");
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
mouse(WM_MOUSEMOVE,{140,150},true);
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
"pressing Shift mid-stroke anchors at the last drawn point");
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
mouse(WM_MOUSEMOVE,{150,200},true);
mouse(WM_LBUTTONUP,{150,210},true);
SetKeyboardState(savedKeys);
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
c.marks.clear(); c.selected=-1;
for(int sx:{-1,1}) for(int sy:{-1,1}) {
mouse(WM_LBUTTONDOWN,{160,120},true);
mouse(WM_MOUSEMOVE,{160+sx,120},true);
for(int step=2;step<=90;++step)
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
c.marks.clear(); c.selected=-1;
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
for(int step=1;step<=90;++step)
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
c.marks.clear(); c.selected=-1;
}
for(int sx:{-1,1}) for(int sy:{-1,1}) {
mouse(WM_LBUTTONDOWN,{160,120},true);
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
"Shift snaps to exact 45 degree diagonals in all four directions");
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
c.marks.clear(); c.selected=-1;
}
mouse(WM_LBUTTONDOWN,{40,40},true);
mouse(WM_MOUSEMOVE,{100,40},true);
mouse(WM_MOUSEMOVE,{160,100},true);
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
"large deviation turns a horizontal segment diagonally without losing its corner");
mouse(WM_MOUSEMOVE,{200,100},true);
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
"small perpendicular deviation keeps the diagonal axis");
mouse(WM_MOUSEMOVE,{260,120},true);
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
"diagonal deviation beyond 50 pixels turns back to horizontal");
mouse(WM_LBUTTONUP,{260,120},true);
c.marks.clear(); c.selected=-1;
for(int sx:{-1,1}) for(int sy:{-1,1}) {
POINT start{sx>0?310L:9L,120};
mouse(WM_LBUTTONDOWN,start,true);
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
auto end=c.stroke.to;
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
std::abs(end.x-start.x)==std::abs(end.y-start.y),
"diagonal stays at 45 degrees and inside image boundaries");
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
c.marks.clear(); c.selected=-1;
}
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
mouse(WM_LBUTTONDOWN,{40,40});
mouse(WM_MOUSEMOVE,{100,70});
mouse(WM_LBUTTONUP,{120,90});
SetKeyboardState(savedKeys);
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
c.marks[0].to.x==120 && c.marks[0].to.y==90,
"Ctrl keeps its existing drawing behavior without constraining the pencil");
c.marks.clear(); c.selected=-1;
}
for(int size:{32,48,64,96}) {
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
ICONINFO info{};
@@ -76,7 +175,7 @@ int main()
}
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
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);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
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;
}
}
+122
View File
@@ -1,7 +1,9 @@
#include "autocorrect.h"
#include "history_preview.h"
#include "ocr.h"
#include "richtext.h"
#include "storage.h"
#include "diagnostics.h"
#include <algorithm>
#include <iostream>
#include <map>
@@ -21,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
bool thrown = false;
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();
}
catch (...)
@@ -44,6 +60,34 @@ int main(int argc, char **argv)
}
try
{
{
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
{"problem", true}, {"verification", "read-back-via-accessibility"},
{"original", "hello\n\"quoted\""}, {"expected", ks::utf8(L"привіт")}, {"actual", "hello"},
{"context", "copy\npaste"}};
auto encoded = L"2026-10-31 12:00:00 Operation " + ks::wide(record.dump());
auto rows = ks::parseOperationLog(encoded);
require(rows.size() == 1 && rows[0].process == L"editor.exe" && rows[0].problem,
"structured operation is one filterable row");
require(rows[0].details.find(L"привіт") != std::wstring::npos &&
rows[0].details.find(L"copy\r\npaste") != std::wstring::npos,
"operation details preserve Unicode and normalize multiline text");
record["problem"] = false;
record["outcome"] = "Paste verified";
rows = ks::parseOperationLog(encoded + L"\n2026-10-31 12:00:01 Operation " + ks::wide(record.dump()));
require(rows.size() == 2 && !rows[1].problem, "verified operations remain distinguishable from problems");
rows = ks::parseOperationLog(L"old incident\n first stage\n second stage\n2026-10-31 Operation {broken");
require(rows.size() == 2 && rows[0].details.find(L"second stage") != std::wstring::npos &&
rows[1].details.find(L"{broken") != std::wstring::npos,
"legacy context is grouped and malformed records remain inspectable");
require(ks::conversionReadbackOutcome(L"new", L"old", L"new") == L"Paste verified",
"expected readback verifies paste");
require(ks::conversionReadbackOutcome(L"old", L"old", L"new") == L"Original text remained",
"original readback identifies unchanged editor text");
require(ks::conversionReadbackOutcome(L"other", L"old", L"new") == L"Readback differs",
"unexpected readback is never reported as success");
}
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
"diagnostic word preserved");
require(ks::diagnosticText(L"\"\\\n\r\t") ==
@@ -241,6 +285,54 @@ int main(int argc, char **argv)
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
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);
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
{
auto shared = supplemental.suggest(typed, 0, layouts, options);
require(shared && shared->text == expected && shared->target == 2,
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
}
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
"different dictionary results remain ambiguous");
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.autoWords[L"uk"].push_back(L"робить");
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
"personal Ukrainian word accepts a punctuation key that maps to a letter");
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
"punctuation-key correction still rejects digits");
options.autoMinLength = 1;
require(!supplemental.suggest(L"ye ", 0, layouts, options),
"known English ye remains protected at minimum length one");
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");
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
@@ -253,6 +345,12 @@ int main(int argc, char **argv)
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
@@ -335,6 +433,25 @@ int main(int argc, char **argv)
auto temp = std::filesystem::temp_directory_path() /
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
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::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
@@ -384,11 +501,16 @@ int main(int argc, char **argv)
settings.autoPrefixChars = L"(#";
settings.autoSuffixChars = L");%";
settings.autoNever = {L"myWord"};
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
settings.autoExcluded = {L"Code.exe"};
settings.cycle = {L"en", L"uk"};
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {});
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
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),
"configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
+8
View File
@@ -10,6 +10,12 @@ try {
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
Assert ($null -ne $binaryVersion) 'executable has a stable version'
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
@@ -24,6 +30,8 @@ try {
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
+31
View File
@@ -0,0 +1,31 @@
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
Start-UpdateProgress
try {
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
if ($Network) {
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
}
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
Show-UpdateProgress $stage 'Update stage under test'
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
}
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
'Passed updater progress UI checks'
} finally {
$script:progressWindow.Dispose()
$script:progressWindow = $null
}
+6
View File
@@ -3,6 +3,8 @@ from pathlib import Path
import urllib.request, hashlib, json
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
SUPPLEMENTAL={'uk': {'реліз': 50001}}
ROOT.mkdir(exist_ok=True)
manifest={}
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 int(count)<5:continue
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))
(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()}
if lang in SUPPLEMENTAL:
manifest[lang]['supplementalWords']=SUPPLEMENTAL[lang]
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
print(json.dumps(manifest,indent=2))