Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22edece7cf | |||
| 908985d775 | |||
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 |
@@ -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.
|
||||
+18
-4
@@ -1,14 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.14 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.26 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
target_link_libraries(kscore PUBLIC winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
@@ -17,6 +17,16 @@ 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(KSwitcherSyncTests tests/sync.cpp)
|
||||
target_include_directories(KSwitcherSyncTests PRIVATE vendor)
|
||||
target_link_libraries(KSwitcherSyncTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherSyncTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherSyncTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
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 +49,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,14 +59,18 @@ 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 sync-regressions COMMAND KSwitcherSyncTests)
|
||||
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 screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||
|
||||
@@ -90,6 +90,10 @@ broker can load them even when the profile directory is inaccessible to it.
|
||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
|
||||
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
|
||||
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
|
||||
must be saved or copied again before closing without a warning.
|
||||
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
||||
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||
@@ -107,10 +111,10 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
|
||||
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, triangles, trapezoids, text blocks,
|
||||
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
||||
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
||||
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
|
||||
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; shape and text attachments
|
||||
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
||||
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
||||
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
||||
@@ -158,13 +162,47 @@ resize; circles keep equal width and height. Move shapes by their outlines, adju
|
||||
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
||||
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||
for shapes.
|
||||
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
||||
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
||||
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
||||
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
||||
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
||||
The wheel without Ctrl still adjusts the object under the cursor.
|
||||
|
||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||
after mouse release, nearly straight strokes become editable straight lines at their drawn angle,
|
||||
preserving endpoints, color, thickness, line style and arrowheads. Short strokes, broad curves and
|
||||
retraced strokes are not straightened. Other strokes receive light smoothing to reduce jitter.
|
||||
Confidently recognized closed outlines become
|
||||
rectangles, circles, ellipses, triangles or trapezoids (including tilted outlines), stored as shape objects.
|
||||
Drag either bounding-box corner handle to resize the entire recognized outline; circles keep equal
|
||||
width and height. Shapes can also be moved, recolored and cloned.
|
||||
Every shape has a round rotation handle above its outline. Drag it to rotate around the center;
|
||||
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
||||
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
||||
undo and redo it. The square handle still controls outline thickness.
|
||||
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
||||
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
||||
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
||||
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||
Oval fitting tolerates uneven contours and small closing gaps or overlaps; broad open arcs and
|
||||
repeated loops remain freehand strokes.
|
||||
Press **Ctrl+Z** once to restore the original stroke, again to remove it; **Ctrl+Y** reapplies the improvement.
|
||||
Small strokes, strokes linked to other objects, and any stroke drawn partly with Shift are left unchanged.
|
||||
Ambiguous shapes are only smoothed. To edit the resulting curve,
|
||||
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.
|
||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
||||
The window close button asks for confirmation before discarding changes that have not been saved or copied.
|
||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||
The shortcut is configurable in Settings under **Screen capture**.
|
||||
@@ -216,15 +254,27 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
## Implemented
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
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
|
||||
@@ -266,6 +316,12 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
||||
|
||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||
in the context menu opens the original image in the built-in screenshot editor.
|
||||
Annotate it there, then copy the result. Opening the editor leaves the
|
||||
clipboard and original history entry unchanged. Text items still open as text;
|
||||
Enter and double-click still paste the selected item.
|
||||
|
||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||
|
||||
## Data and migration
|
||||
@@ -275,27 +331,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.
|
||||
|
||||
@@ -316,6 +383,105 @@ This is a working native implementation, not a claim of complete UI/release pari
|
||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
||||
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
||||
|
||||
## OpenBao synchronization (experimental, opt-in)
|
||||
|
||||
The **Sync...** button is available in Settings for everyone. Synchronization remains disabled
|
||||
until you configure your own OpenBao and enable it. Connection configuration is saved separately
|
||||
in `sync.json`. The portable data directory is normally `data/` beside the executable; installations
|
||||
without a writable application directory use the existing local-app-data fallback.
|
||||
No server address or account is included in the application.
|
||||
|
||||
The first provider is **OpenBao KV v2**. The dialog accepts an HTTPS server origin, username,
|
||||
password, KV mount (default `kswitcher`), profile path (default `personal/settings`) and userpass
|
||||
authentication mount (default `userpass`). Use the same profile on each of your PCs. This is
|
||||
administrator-provisioned private access, not public account registration. Non-root namespaces,
|
||||
reverse-proxy URL prefixes, MFA login flows and other providers are not implemented yet.
|
||||
|
||||
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
|
||||
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
|
||||
A blank password keeps the existing Windows credential for that server, login mount and username.
|
||||
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
||||
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
||||
kept in memory for one sync attempt, with best-effort self-revocation afterwards. Configure bounded
|
||||
token lifetimes on the server as well (for example a 5-minute default and 10-minute maximum).
|
||||
**Disconnect / forget password** disables synchronization and removes the current connection's
|
||||
saved credential. A request already sent to the server may still complete; its downloaded settings
|
||||
will not be applied after the connection changes. Old credentials for other connection identities
|
||||
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
|
||||
|
||||
First connection is deliberately explicit:
|
||||
|
||||
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
||||
- **Download remote** replaces this PC's shared settings with the server profile.
|
||||
- If both profiles are identical, ordinary Sync can establish the baseline without a replacement.
|
||||
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
|
||||
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
||||
|
||||
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
|
||||
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
|
||||
continues offline. Independent changes to different settings fields are merged against the last
|
||||
successful baseline. **Resolve individual conflicts...** shows the name and local/server values
|
||||
for each conflict. Choose either value for selected settings and apply your choices; unselected
|
||||
conflicts stay pending. Other, nonconflicting changes continue to synchronize. Choices are checked
|
||||
against freshly read values before writing, so a stale dialog cannot overwrite a newer edit.
|
||||
Conflicting shortcuts are separate settings, including bindings for individual keyboard layouts.
|
||||
Exclusions and personal dictionary words merge by membership. Ordered layout/dictionary lists
|
||||
remain single settings because their ordering affects behavior.
|
||||
|
||||
Snippets have persistent IDs that survive renaming. Different snippets merge independently;
|
||||
trigger and text edits within a snippet can merge too. Deleting a snippet retains a deletion
|
||||
marker, so an unchanged offline copy cannot bring it back. Deletion versus a concurrent edit
|
||||
is an explicit conflict, offering the deleted or edited version. Duplicate triggers and shortcut
|
||||
collisions created by independent edits are also presented for resolution. Deletion markers are
|
||||
retained without automatic expiry; recreating a snippet in the editor gives it a new ID.
|
||||
|
||||
Existing profiles migrate automatically: legacy snippet IDs derive deterministically from the
|
||||
original trigger, then are stored permanently. Sync writes use envelope version 2; older clients
|
||||
refuse this version instead of dropping IDs/deletions. Update all participating PCs before using
|
||||
the new merge behavior. Existing version-1 profiles and local baselines remain readable.
|
||||
|
||||
Download/Upload are still available for deliberate whole-profile recovery/initialization, not
|
||||
required for ordinary conflicts. Every server write uses the version just read as a CAS
|
||||
condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
|
||||
Deleted server versions are not silently recreated.
|
||||
|
||||
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
||||
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
||||
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
||||
history, screenshots and dictionary files are never uploaded. Missing keyboard layouts block
|
||||
applying a remote profile until those Windows layouts are installed, preventing lost bindings.
|
||||
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
||||
|
||||
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||
change (`settings-before-sync.bin`) are protected with Windows DPAPI and belong to this Windows
|
||||
account/machine. Reinstallation recovery uses the remote profile and a fresh login; copying these
|
||||
local DPAPI files to a new machine does not recover them. For an older server snapshot, use OpenBao
|
||||
KV version history to restore that snapshot as the latest version, then choose Download remote.
|
||||
The app does not yet provide a server-history browser. Keep ordinary server backups too.
|
||||
|
||||
OpenBao administrator setup:
|
||||
|
||||
1. Enable a KV **version 2** engine at `kswitcher`; enable **Require Check-and-Set** and set
|
||||
maximum versions to a suitable value, such as 30.
|
||||
2. Create a dedicated userpass account, with a policy restricted to the chosen profile. Do not
|
||||
use the administrator account. For the defaults, the application needs only:
|
||||
|
||||
```hcl
|
||||
path "kswitcher/data/personal/settings" {
|
||||
capabilities = ["create", "read", "update"]
|
||||
}
|
||||
path "auth/token/revoke-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
```
|
||||
|
||||
3. Set bounded token lifetimes on that account/auth mount and enter its credentials in KSwitcher.
|
||||
Keep the server's address and TLS certificate reachable/trusted on each participating PC.
|
||||
|
||||
`SyncProvider` separates versioned read/conditional-write operations from merging, local state
|
||||
and UI integration, so another service can be added without replacing the synchronization logic.
|
||||
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
|
||||
|
||||
## Build
|
||||
|
||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||
@@ -356,6 +522,8 @@ protect already valid input, and ambiguous alternatives remain unchanged regardl
|
||||
**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.
|
||||
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
|
||||
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
|
||||
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||
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.
|
||||
@@ -447,6 +615,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
@@ -1,22 +1,25 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.14 — 2026-10-30
|
||||
## v0.6.26 — 2026-11-02
|
||||
|
||||
## Changes
|
||||
- Restore resizing for the automatic-correction settings dialog with an adaptive layout.
|
||||
- Open the dialog with larger personal-dictionary fields. The three dictionaries share additional width evenly and receive additional height as the window grows.
|
||||
- Expand exclusion lists and punctuation fields horizontally, and move lower settings and action buttons with the window while preserving spacing.
|
||||
- Retain scrolling when the dialog must fit a small screen.
|
||||
|
||||
Includes the earlier automatic-correction and log-viewer improvements. Existing modern releases update directly to this version without installing intermediate releases.
|
||||
- Settings → Sync… is available to everyone. Connect your own OpenBao KV v2 with a username and password; synchronization is disabled by default and no server is built in.
|
||||
- Synchronize at startup, approximately two seconds after saving settings, and every 10 minutes in the background. Local operation continues offline.
|
||||
- Merge independent settings changes and resolve conflicts individually, with local/server values shown side by side. Unresolved values stay pending while independent changes synchronize. Every server write uses CAS to protect concurrent changes.
|
||||
- Snippets have stable IDs and retained deletion records. Independent snippets, renames and text edits can merge; deletion versus editing and duplicate shortcuts/triggers receive explicit conflict handling.
|
||||
- Personal dictionaries, exclusions, shortcuts and layout preferences synchronize. Startup, history, local paths, connection details and credentials stay on the PC. Passwords use Windows Credential Manager; cached sync state uses DPAPI.
|
||||
- Existing profiles migrate to sync envelope version 2. Update every participating PC before using the new synchronization behavior.
|
||||
|
||||
## Validation
|
||||
220 native regression checks, 472 input/UI checks including dictionary sizing and footer placement, screenshot editor and notification registration checks, 45 updater checks and updater progress checks. The resized dialog was also confirmed in the development build by the user.
|
||||
91 sync/protocol/conflict checks, 423 native regressions, 510 input/UI checks, screenshot editor and PNG save regressions, shared wheel handling, notification registration, 48 updater checks and updater progress checks. Initial upload and personal-dictionary synchronization were confirmed against the user's OpenBao. Two-physical-PC production testing is still pending.
|
||||
|
||||
## Limitations
|
||||
OpenBao synchronization is experimental and requires a reachable HTTPS server with a trusted certificate, KV v2 and a provisioned userpass account/policy. It does not provide account registration, MFA login, non-root namespaces or reverse-proxy URL prefixes. Ordered layout/language lists are resolved as whole settings. Deletion records are retained without expiry. Missing keyboard layouts block applying a profile. Download/Upload replace a whole profile and are intended for initial setup or deliberate recovery; use Resolve individual conflicts for normal conflicts. Existing browser-selection and clipboard/accessibility limitations remain unchanged.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, passwords, server address, clipboard history, Tesseract installation or language models.
|
||||
|
||||
Build and package:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
4939b83b2cd641e969107091012fe12340b09d73ec1b7f7292c6809d226e05f5 KSwitcher-win-x64.zip
|
||||
99a863ddb82d3ec868d874ba5986d6552ab8e5b2fd61d0e227577b35f2657383 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -10,11 +10,13 @@ $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')
|
||||
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||
}
|
||||
@@ -27,11 +29,11 @@ try {
|
||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||
@@ -39,11 +41,19 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||
& .\build\KSwitcherSyncTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
||||
& $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/sync.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
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||
& .\build\KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||
@@ -51,6 +61,8 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe --save-only
|
||||
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||
try {
|
||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
@@ -65,13 +77,13 @@ try {
|
||||
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
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||
& .\build\KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||
}
|
||||
if ($Ocr) {
|
||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||
& .\build\KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||
|
||||
@@ -17,7 +17,7 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
|
||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||
|
||||
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
|
||||
Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
|
||||
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
||||
They are included with the default unlimited rank setting and recorded in sources.json.
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
"uk": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||
"words": 42550,
|
||||
"bytes": 898227,
|
||||
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
|
||||
"words": 42551,
|
||||
"bytes": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
|
||||
"supplementalWords": {
|
||||
"\u0440\u0435\u043b\u0456\u0437": 50001
|
||||
"\u0440\u0435\u043b\u0456\u0437": 50001,
|
||||
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
|
||||
@@ -27337,6 +27337,7 @@
|
||||
поторопись 9062
|
||||
поторопитесь 20656
|
||||
поторопиться 15390
|
||||
поточні 50001
|
||||
потрапив 4168
|
||||
потрапила 7383
|
||||
потрапили 6631
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,14,0
|
||||
PRODUCTVERSION 0,6,14,0
|
||||
FILEVERSION 0,6,26,0
|
||||
PRODUCTVERSION 0,6,26,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.14"
|
||||
VALUE "FileVersion", "0.6.26"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+194
-44
@@ -1,7 +1,10 @@
|
||||
#include "app.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#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>
|
||||
@@ -9,9 +12,21 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
#include "app_sync.inl"
|
||||
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 +126,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 +154,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 +172,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())
|
||||
@@ -191,7 +229,8 @@ class Transaction
|
||||
return selectPrevious(n, [&] { return valid(); });
|
||||
}
|
||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||
const std::function<bool(const std::wstring &)> &matches)
|
||||
const std::function<bool(const std::wstring &)> &matches,
|
||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||
{
|
||||
// WebView word boundaries are only candidates; exact verification is mandatory.
|
||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||
@@ -211,7 +250,25 @@ class Transaction
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
return select(count) ? copy() : std::wstring{};
|
||||
auto text = select(count) ? copy() : std::wstring{};
|
||||
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||
if (valid() && !matches(text) && !text.empty() &&
|
||||
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
|
||||
caretSteps(text) < count)
|
||||
{
|
||||
const auto missing = count - caretSteps(text);
|
||||
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
|
||||
for (size_t i = 0; i < missing; ++i)
|
||||
{
|
||||
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
text = copy();
|
||||
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
bool paste(const Clip &clip)
|
||||
{
|
||||
@@ -267,6 +324,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (syncWorker.joinable()) syncWorker.join();
|
||||
if (updateWorker.joinable())
|
||||
updateWorker.join();
|
||||
if (keyboardHook)
|
||||
@@ -397,7 +455,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);
|
||||
@@ -421,8 +479,16 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
||||
return true;
|
||||
}
|
||||
|
||||
void App::applySettings(const Settings &s)
|
||||
void App::applySettings(const Settings &requested)
|
||||
{
|
||||
Settings s = requested;
|
||||
std::set<std::wstring> present, removed(s.snippetTombstones.begin(), s.snippetTombstones.end());
|
||||
removed.insert(settings.snippetTombstones.begin(), settings.snippetTombstones.end());
|
||||
for (auto &snippet : s.snippets) { snippet.id = snippetId(snippet); present.insert(snippet.id); }
|
||||
for (const auto &snippet : settings.snippets)
|
||||
if (!present.contains(snippetId(snippet))) removed.insert(snippetId(snippet));
|
||||
for (const auto &id : present) removed.erase(id);
|
||||
s.snippetTombstones.assign(removed.begin(), removed.end());
|
||||
bool autostartChanged = false;
|
||||
AutoCorrector predictor;
|
||||
validateSettings(s);
|
||||
@@ -470,6 +536,7 @@ void App::applySettings(const Settings &s)
|
||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||
scheduleHistoryOcr(s);
|
||||
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||
}
|
||||
@@ -566,7 +633,8 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
return;
|
||||
}
|
||||
run.record(ch, fg, layout, GetTickCount64());
|
||||
if (settings.autoCorrect && mods == 0 && 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)
|
||||
{
|
||||
for (int source = 0; source < (int)layouts.size(); ++source)
|
||||
@@ -619,7 +687,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;
|
||||
}
|
||||
}
|
||||
@@ -693,12 +772,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
bool App::beginTypingDeferral(HWND target, uint64_t version)
|
||||
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()
|
||||
@@ -710,7 +790,12 @@ void App::replayTyping()
|
||||
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())
|
||||
@@ -826,14 +911,30 @@ void App::transform(int command, int snippet)
|
||||
L" version=" + std::to_wstring(currentVersion) +
|
||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||
}
|
||||
const bool preDeferred = command == AutoCorrect && autoPending;
|
||||
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;
|
||||
if (preDeferred)
|
||||
{
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
}
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -865,7 +966,7 @@ void App::transform(int command, int snippet)
|
||||
} 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)
|
||||
{
|
||||
@@ -890,21 +991,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 = [&]
|
||||
@@ -946,9 +1060,9 @@ void App::transform(int command, int snippet)
|
||||
return;
|
||||
}
|
||||
reason = L"clipboard-snapshot";
|
||||
if (command == AutoCorrect)
|
||||
if (command == AutoCorrect || snippet >= 0)
|
||||
{
|
||||
if (!beginTypingDeferral(target, version))
|
||||
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||
return;
|
||||
reset.deferred = true;
|
||||
}
|
||||
@@ -1043,13 +1157,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;
|
||||
}
|
||||
@@ -1057,7 +1169,9 @@ void App::transform(int command, int snippet)
|
||||
{
|
||||
text =
|
||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
|
||||
[&](const std::wstring &candidate)
|
||||
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
||||
hint = recent.match(text, available, physicalLayout());
|
||||
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
@@ -1074,6 +1188,7 @@ void App::transform(int command, int snippet)
|
||||
previous->version == version)
|
||||
{
|
||||
expected = previous->result;
|
||||
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
|
||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||
[&](const std::wstring &candidate)
|
||||
{ return newlines(candidate) == newlines(previous->result); });
|
||||
@@ -1121,16 +1236,13 @@ void App::transform(int command, int snippet)
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
int source = detect(text, available, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
|
||||
to = nextConversionLayout(text, available, cycle, hint);
|
||||
}
|
||||
result = convertTo(text, available, to, hint);
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -1141,10 +1253,27 @@ void App::transform(int command, int snippet)
|
||||
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)
|
||||
@@ -1162,12 +1291,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))
|
||||
{
|
||||
@@ -1517,15 +1656,20 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case AutoCorrectMessage:
|
||||
if (app->autoPending)
|
||||
{
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||
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))
|
||||
@@ -1561,6 +1705,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (wp == 6)
|
||||
{
|
||||
app->pollSync();
|
||||
return 0;
|
||||
}
|
||||
if (wp == 5)
|
||||
{
|
||||
// After the initial 5-second check, repeat while the resident stays open.
|
||||
@@ -1663,6 +1812,7 @@ int App::execute()
|
||||
scheduleHistoryOcr(settings);
|
||||
addTray();
|
||||
SetTimer(window, 1, 60000, nullptr);
|
||||
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||
if (!registrationWarning.empty())
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "autocorrect.h"
|
||||
#include "storage.h"
|
||||
#include "update.h"
|
||||
#include "sync.h"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
@@ -78,6 +79,9 @@ class App
|
||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||
void resetInput();
|
||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
||||
Bytes syncConflictData();
|
||||
void pollSync();
|
||||
static App *current;
|
||||
|
||||
private:
|
||||
@@ -96,6 +100,16 @@ class App
|
||||
Settings historyOcrSettings;
|
||||
void scheduleHistoryOcr(const Settings &);
|
||||
std::thread updateWorker;
|
||||
std::thread syncWorker;
|
||||
std::atomic<bool> syncDone{false};
|
||||
bool syncRunning{};
|
||||
ULONGLONG nextSync{}, syncRetryAfter{};
|
||||
SyncResult syncResult;
|
||||
SyncConfig syncConnection;
|
||||
SyncConfig conflictConnection;
|
||||
std::vector<SyncConflict> pendingSyncConflicts;
|
||||
Bytes syncSnapshot;
|
||||
std::wstring syncStatus = L"Not connected.";
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
std::wstring availableUpdate, notifiedUpdate;
|
||||
bool (*updateNotifier)(const std::wstring &){};
|
||||
@@ -109,8 +123,10 @@ class App
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
int snippetPending = -1;
|
||||
bool snippetTyping{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t);
|
||||
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);
|
||||
@@ -118,6 +134,7 @@ class App
|
||||
};
|
||||
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 &);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Included by app.cpp; all App mutations happen on its window thread.
|
||||
Bytes App::syncConflictData()
|
||||
{
|
||||
if (loadSyncConfig(data) != conflictConnection) return encodeSyncConflicts({});
|
||||
return encodeSyncConflicts(pendingSyncConflicts);
|
||||
}
|
||||
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
if (action == 0) return syncStatus;
|
||||
if (syncRunning) return L"Synchronization is already running.";
|
||||
auto c = loadSyncConfig(data);
|
||||
if (!c.enabled) return syncStatus = L"Synchronization is disabled.";
|
||||
validateSyncConfig(c);
|
||||
if (action == 4 && (c != conflictConnection || choices.empty()))
|
||||
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
|
||||
if (busy) return L"Wait for the current text operation to finish.";
|
||||
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
|
||||
if (syncWorker.joinable()) syncWorker.join();
|
||||
syncConnection = c;
|
||||
syncSnapshot = encodeSettings(settings);
|
||||
auto snapshot = settings;
|
||||
auto availableLayouts = layouts;
|
||||
syncRunning = true;
|
||||
syncDone = false;
|
||||
syncStatus = L"Connecting to OpenBao...";
|
||||
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||
try
|
||||
{
|
||||
syncWorker = std::thread([this, c, snapshot, availableLayouts, action, choices] {
|
||||
SyncResult result;
|
||||
try
|
||||
{
|
||||
auto provider = makeSyncProvider(c);
|
||||
// An explicit choice also repairs a missing/corrupt local baseline.
|
||||
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
|
||||
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
||||
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
|
||||
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts); }, choices);
|
||||
}
|
||||
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
||||
catch (const std::exception &e) { result.status = wide(e.what()); }
|
||||
syncResult = std::move(result);
|
||||
syncDone.store(true);
|
||||
});
|
||||
}
|
||||
catch (...) { syncRunning = false; throw; }
|
||||
return syncStatus;
|
||||
}
|
||||
void App::pollSync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (syncRunning && syncDone.load())
|
||||
{
|
||||
if (busy) return;
|
||||
syncWorker.join();
|
||||
syncRunning = false;
|
||||
syncDone = false;
|
||||
syncStatus = syncResult.status;
|
||||
if (syncResult.success)
|
||||
{
|
||||
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != syncSnapshot)
|
||||
{
|
||||
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
||||
nextSync = GetTickCount64() + 2000;
|
||||
return;
|
||||
}
|
||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts);
|
||||
if (encodeSettings(next) != syncSnapshot)
|
||||
{
|
||||
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
||||
applySettings(next);
|
||||
}
|
||||
saveSyncBase(data, syncConnection, syncResult.base.empty() ? syncResult.settings : syncResult.base);
|
||||
pendingSyncConflicts = syncResult.conflicts;
|
||||
conflictConnection = syncConnection;
|
||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||
wchar_t stamp[32]{};
|
||||
swprintf(stamp, std::size(stamp), L" (%02u:%02u:%02u)", time.wHour, time.wMinute, time.wSecond);
|
||||
syncStatus += stamp;
|
||||
}
|
||||
// Back off after errors (including bad credentials) instead of repeatedly
|
||||
// hitting userpass lockout. The explicit Sync button remains available.
|
||||
syncRetryAfter = syncResult.success ? 0 : GetTickCount64() + 15 * 60000;
|
||||
nextSync = syncResult.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
||||
}
|
||||
if (!syncRunning && GetTickCount64() >= nextSync)
|
||||
{
|
||||
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||
if (loadSyncConfig(data).enabled) syncCommand(1);
|
||||
}
|
||||
}
|
||||
catch (const nlohmann::json::exception &) { syncStatus = L"Invalid local sync configuration or cached profile."; nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||
catch (const std::exception &e) { syncStatus = wide(e.what()); nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||
}
|
||||
@@ -82,6 +82,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
||||
{
|
||||
current = this;
|
||||
data = dataDirectory();
|
||||
layouts = readLayouts();
|
||||
}
|
||||
App::~App()
|
||||
@@ -110,6 +111,13 @@ void App::reloadLayouts()
|
||||
settings = decodeSettings(bytes, layouts);
|
||||
}
|
||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
||||
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
|
||||
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
|
||||
request(*this, ConfigSync, Bytes{static_cast<unsigned char>(action)});
|
||||
return wide(std::string(result.begin(), result.end()));
|
||||
}
|
||||
void App::setHotkeyCapture(HWND field)
|
||||
{
|
||||
hotkeyCapture = field;
|
||||
@@ -180,6 +188,12 @@ int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||
{
|
||||
HWND popup = GetLastActivePopup(app.settingsWindow);
|
||||
while (!IsWindowEnabled(popup))
|
||||
{
|
||||
HWND next = GetLastActivePopup(popup);
|
||||
if (next == popup) break;
|
||||
popup = next;
|
||||
}
|
||||
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
||||
}
|
||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||
|
||||
@@ -40,6 +40,25 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
{
|
||||
switch (message.dwData)
|
||||
{
|
||||
case ConfigSyncConflicts:
|
||||
reply = app.syncConflictData();
|
||||
break;
|
||||
case ConfigSyncResolve:
|
||||
{
|
||||
if (!message.cbData) throw std::runtime_error("Empty conflict choices.");
|
||||
auto first = static_cast<const unsigned char *>(message.lpData);
|
||||
auto status = utf8(app.syncCommand(4, decodeSyncChoices(Bytes(first, first + message.cbData))));
|
||||
reply.assign(status.begin(), status.end());
|
||||
break;
|
||||
}
|
||||
case ConfigSync:
|
||||
{
|
||||
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 3)
|
||||
throw std::runtime_error("Invalid sync command.");
|
||||
auto status = utf8(app.syncCommand(*static_cast<const unsigned char *>(message.lpData)));
|
||||
reply.assign(status.begin(), status.end());
|
||||
break;
|
||||
}
|
||||
case ConfigSave:
|
||||
{
|
||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||
|
||||
+3
-1
@@ -5,7 +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, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
|
||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||
ConfigSyncResolve = ConfigRead + 10, ConfigEnd = ConfigRead + 11;
|
||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||
bool configurationPeer(HWND, const wchar_t *filename);
|
||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||
|
||||
+39
-1
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
|
||||
}
|
||||
return best < 0 ? text : convert(text, layouts[best], to);
|
||||
}
|
||||
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const std::vector<int> &cycle, int hint)
|
||||
{
|
||||
if (cycle.empty())
|
||||
return -1;
|
||||
int source = detect(text, layouts, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||
// Compare every candidate with the original input, never an intermediate conversion.
|
||||
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||
{
|
||||
int target = cycle[(start + offset) % cycle.size()];
|
||||
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||
return target;
|
||||
}
|
||||
return cycle[start];
|
||||
}
|
||||
std::wstring cycleCase(const std::wstring &text)
|
||||
{
|
||||
if (text == upper(text))
|
||||
@@ -261,7 +281,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;
|
||||
@@ -449,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
||||
keys.pop_back();
|
||||
at = now;
|
||||
}
|
||||
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||
return -1;
|
||||
Run tail = *this;
|
||||
tail.keys = keys.substr(keys.size() - text.size());
|
||||
return tail.match(text, layouts, physical);
|
||||
}
|
||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.size() != keys.size() || overflow)
|
||||
|
||||
@@ -24,16 +24,22 @@ std::wstring upper(std::wstring text);
|
||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||
const std::vector<int> &cycle, int hint);
|
||||
std::wstring cycleCase(const std::wstring &);
|
||||
std::wstring transliterate(const std::wstring &);
|
||||
struct Snippet
|
||||
{
|
||||
std::wstring trigger, text;
|
||||
std::wstring id;
|
||||
};
|
||||
std::wstring snippetId(const Snippet &);
|
||||
std::wstring newSnippetId();
|
||||
struct Template
|
||||
{
|
||||
std::wstring text, suffix;
|
||||
int cursors = 0;
|
||||
int enters = 0;
|
||||
};
|
||||
Template parseTemplate(const std::wstring &);
|
||||
struct Hotkey
|
||||
@@ -70,6 +76,7 @@ struct Settings
|
||||
std::vector<std::wstring> cycle, direct, directIds;
|
||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
std::vector<Snippet> snippets;
|
||||
std::vector<std::wstring> snippetTombstones;
|
||||
};
|
||||
// Consume repeats and key-up for intercepted chords, even if settings change.
|
||||
struct HotkeyHook
|
||||
@@ -91,5 +98,6 @@ struct Run
|
||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||
void backspace(HWND, ULONGLONG);
|
||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
};
|
||||
} // namespace ks
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace ks {
|
||||
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||
// visible preview to admit another visible preview: that creates a paint loop.
|
||||
template<class T>
|
||||
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||
const std::set<std::wstring> &visible, size_t limit=32)
|
||||
{
|
||||
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
||||
if(!visible.contains(it->first)) it=cache.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
}
|
||||
+37
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
||||
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
||||
return pixels;
|
||||
}
|
||||
Bytes encodePng(const Pixels &p)
|
||||
{
|
||||
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
|
||||
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
|
||||
throw std::runtime_error("Invalid PNG image dimensions");
|
||||
Com<IWICImagingFactory> factory;
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(factory.out())));
|
||||
Com<IStream> stream;
|
||||
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
|
||||
Com<IWICBitmapEncoder> encoder;
|
||||
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
|
||||
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
|
||||
Com<IWICBitmapFrameEncode> frame;
|
||||
check(encoder->CreateNewFrame(frame.out(), nullptr));
|
||||
check(frame->Initialize(nullptr));
|
||||
check(frame->SetSize(p.width, p.height));
|
||||
auto format = GUID_WICPixelFormat32bppBGRA;
|
||||
check(frame->SetPixelFormat(&format));
|
||||
if (format != GUID_WICPixelFormat32bppBGRA)
|
||||
throw std::runtime_error("Unsupported PNG pixel format");
|
||||
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
|
||||
const_cast<BYTE *>(p.bgra.data())));
|
||||
check(frame->Commit());
|
||||
check(encoder->Commit());
|
||||
STATSTG stat{};
|
||||
check(stream->Stat(&stat, STATFLAG_NONAME));
|
||||
if (stat.cbSize.QuadPart > UINT_MAX)
|
||||
throw std::runtime_error("PNG image is too large");
|
||||
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
|
||||
Bytes bytes((size_t)stat.cbSize.QuadPart);
|
||||
ULONG read{};
|
||||
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
|
||||
if (read != bytes.size())
|
||||
throw std::runtime_error("Cannot read encoded PNG image");
|
||||
return bytes;
|
||||
}
|
||||
Format imageDib(const Pixels &p)
|
||||
{
|
||||
BITMAPINFOHEADER h{};
|
||||
|
||||
@@ -9,6 +9,7 @@ struct Pixels
|
||||
};
|
||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||
Format imageDib(const Pixels &);
|
||||
Bytes encodePng(const Pixels &);
|
||||
std::wstring recognizeText(const Clip &);
|
||||
std::wstring recognizeText(const Clip &, const Settings &);
|
||||
std::filesystem::path tesseractExecutable(const Settings &);
|
||||
|
||||
@@ -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
|
||||
+24
-4
@@ -1,6 +1,8 @@
|
||||
#include "platform.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <imm.h>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
@@ -394,13 +396,14 @@ bool chord(WORD key, UINT mods)
|
||||
bool needsSelectionDelay(HWND target)
|
||||
{
|
||||
const auto name = lower(processName(target));
|
||||
return name == L"ms-teams" || name == L"msedgewebview2";
|
||||
return name == L"ms-teams" || name == L"msedgewebview2" ||
|
||||
name == L"chrome" || name == L"msedge";
|
||||
}
|
||||
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||
{
|
||||
if (count > 100000)
|
||||
return false;
|
||||
// Teams' WebView editor applies selection changes asynchronously. A burst of
|
||||
// Chromium and Teams' WebView editors apply selection changes asynchronously. A burst of
|
||||
// Shift+Left events can all use the same caret position and select only one
|
||||
// character. Give each change time to settle before sending the next one.
|
||||
const auto target = GetForegroundWindow();
|
||||
@@ -624,6 +627,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 +664,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 (...)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
+424
-36
@@ -1,4 +1,5 @@
|
||||
#include "screenshot.h"
|
||||
#include "stroke_improvement.h"
|
||||
#include "ocr.h"
|
||||
#include "../resources/resource.h"
|
||||
#include <algorithm>
|
||||
@@ -28,8 +29,8 @@ bool confirmScreenshotClose(HWND owner)
|
||||
dialog.hMainIcon=icon;
|
||||
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
||||
dialog.pszWindowTitle=L"KSwitcher";
|
||||
dialog.pszMainInstruction=L"Close without copying?";
|
||||
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
|
||||
dialog.pszMainInstruction=L"Close without saving or copying?";
|
||||
dialog.pszContent=L"Your latest screenshot changes have not been saved to a file or copied to the clipboard.";
|
||||
dialog.nDefaultButton=IDNO;
|
||||
dialog.lpCallbackData=(LONG_PTR)owner;
|
||||
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
||||
@@ -53,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
|
||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||
return SUCCEEDED(result) && button==IDYES;
|
||||
}
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,21,19,26,20,12,18,13};
|
||||
struct Surface
|
||||
{
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
@@ -123,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
|
||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||
return true;
|
||||
}
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,19};
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,19};
|
||||
HCURSOR makeToolCursor(int tool,int size)
|
||||
{
|
||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
||||
@@ -148,6 +149,8 @@ HCURSOR makeToolCursor(int tool,int size)
|
||||
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
||||
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
||||
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
||||
case 36: { POINT p[]{{x(23),y(17)},{x(31),y(30)},{x(16),y(30)},{x(23),y(17)}}; Polyline(dc,p,4); break; }
|
||||
case 37: { POINT p[]{{x(20),y(18)},{x(27),y(18)},{x(31),y(30)},{x(16),y(30)},{x(20),y(18)}}; Polyline(dc,p,5); break; }
|
||||
case 19:
|
||||
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
|
||||
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
|
||||
@@ -178,8 +181,19 @@ struct Capture
|
||||
COLORREF currentColor=RGB(225,42,50);
|
||||
COLORREF customColors[16]{};
|
||||
Mark stroke{};
|
||||
bool strokeConstrained{};
|
||||
bool strokeUsedConstraint{};
|
||||
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{};
|
||||
RECT canvas{};
|
||||
double zoom=1,panX=0,panY=0;
|
||||
bool panning=false;
|
||||
POINT panOrigin{};
|
||||
int zoomWheelDelta=0;
|
||||
std::vector<Mark> marks;
|
||||
ScreenshotHistory history;
|
||||
HWND editor{}, inlineText{};
|
||||
@@ -203,6 +217,7 @@ struct Capture
|
||||
int hoveredTool=0;
|
||||
MarkPart hoveredPart=MarkPart::None;
|
||||
std::wstring statusText;
|
||||
std::filesystem::path savedPath;
|
||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
||||
~Capture() {
|
||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||
@@ -234,11 +249,13 @@ void updateStatus(Capture &c)
|
||||
if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot";
|
||||
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
|
||||
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
|
||||
if(c.hoveredTool==28) text=L"Save (Ctrl+S) | Save As... (Ctrl+Shift+S)";
|
||||
}
|
||||
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
||||
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
||||
else if(part>=MarkPart::Vertex0 && part<=MarkPart::Vertex2) text=L"Drag triangle vertex | Other vertices stay fixed";
|
||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows";
|
||||
else if(c.selected>=0) {
|
||||
const auto &m=c.marks[c.selected];
|
||||
@@ -250,9 +267,11 @@ 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): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | 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";
|
||||
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
||||
L"% (Ctrl +/- / wheel; Ctrl+0: fit; middle drag: pan) | "+text;
|
||||
if(text!=c.statusText) {
|
||||
c.statusText=std::move(text);
|
||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
@@ -292,6 +311,12 @@ void positionInlineText(Capture &c)
|
||||
int width=std::min(std::max(120,MulDiv(bounds.right-bounds.left,viewWidth,c.image->width)+24),std::max(32,int(client.right)-x));
|
||||
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
||||
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT editorBounds{x-2,y-2,x-2+width,y-2+height},visible{};
|
||||
IntersectRect(&visible,&editorBounds,&c.canvas);
|
||||
auto clip=IsRectEmpty(&visible)?CreateRectRgn(0,0,0,0):
|
||||
CreateRectRgn(visible.left-editorBounds.left,visible.top-editorBounds.top,
|
||||
visible.right-editorBounds.left,visible.bottom-editorBounds.top);
|
||||
if(!SetWindowRgn(c.inlineText,clip,TRUE)) DeleteObject(clip);
|
||||
}
|
||||
Mark textStyleDefaults(const Mark &source)
|
||||
{
|
||||
@@ -332,6 +357,14 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
||||
}
|
||||
LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(msg==WM_KEYDOWN && (GetKeyState(VK_CONTROL)&0x8000) &&
|
||||
(wp==VK_OEM_PLUS || wp==VK_ADD || wp==VK_OEM_MINUS || wp==VK_SUBTRACT || wp=='0' || wp==VK_NUMPAD0)) {
|
||||
SendMessageW(GetParent(w),msg,wp,lp); return 0;
|
||||
}
|
||||
if(msg==WM_KEYDOWN && wp=='S' && (GetKeyState(VK_CONTROL)&0x8000)) {
|
||||
PostMessageW(GetParent(w),WM_COMMAND,(GetKeyState(VK_SHIFT)&0x8000)?29:28,0); return 0;
|
||||
}
|
||||
if(msg==WM_CHAR && wp==19) return 0; // Ctrl+S is handled by the editor.
|
||||
if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) {
|
||||
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
|
||||
}
|
||||
@@ -361,9 +394,47 @@ void beginMarkText(HWND w,Capture &c,int index)
|
||||
beginInlineText(w,c,label,index); c.editingLabel=true;
|
||||
}
|
||||
}
|
||||
POINT rotateShapePoint(const Mark &m,POINT p,double direction=1)
|
||||
{
|
||||
if(m.shape==ScreenshotShape::None || m.rotation==0) return p;
|
||||
double cx=(m.from.x+m.to.x)/2.0,cy=(m.from.y+m.to.y)/2.0;
|
||||
double a=direction*m.rotation*3.141592653589793/180,cs=std::cos(a),sn=std::sin(a);
|
||||
return {LONG(std::lround(cx+(p.x-cx)*cs-(p.y-cy)*sn)),
|
||||
LONG(std::lround(cy+(p.x-cx)*sn+(p.y-cy)*cs))};
|
||||
}
|
||||
std::vector<POINT> shapeOutline(const Mark &m)
|
||||
{
|
||||
auto points=m.points;
|
||||
if(points.empty()) {
|
||||
auto r=region(m.from,m.to);
|
||||
if(m.shape==ScreenshotShape::Rectangle)
|
||||
points={{r.left,r.top},{r.right,r.top},{r.right,r.bottom},{r.left,r.bottom},{r.left,r.top}};
|
||||
else if(m.shape==ScreenshotShape::Triangle)
|
||||
points={{(r.left+r.right)/2,r.top},{r.right,r.bottom},{r.left,r.bottom},{(r.left+r.right)/2,r.top}};
|
||||
else if(m.shape==ScreenshotShape::Trapezoid)
|
||||
points={{r.left+(r.right-r.left)/4,r.top},{r.right-(r.right-r.left)/4,r.top},
|
||||
{r.right,r.bottom},{r.left,r.bottom},{r.left+(r.right-r.left)/4,r.top}};
|
||||
else {
|
||||
for(int i=0;i<180;++i) {
|
||||
double a=i*2*3.141592653589793/180;
|
||||
points.push_back({LONG(std::lround((r.left+r.right)/2.0+(r.right-r.left)/2.0*std::cos(a))),
|
||||
LONG(std::lround((r.top+r.bottom)/2.0+(r.bottom-r.top)/2.0*std::sin(a)))});
|
||||
}
|
||||
points.push_back(points.front());
|
||||
}
|
||||
}
|
||||
for(auto &p:points) p=rotateShapePoint(m,p);
|
||||
return points;
|
||||
}
|
||||
RECT markBounds(const Mark &m)
|
||||
{
|
||||
if(m.isText()) return screenshotTextBounds(m);
|
||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||
auto points=shapeOutline(m); RECT r{points[0].x,points[0].y,points[0].x,points[0].y};
|
||||
for(auto p:points) { r.left=std::min(r.left,p.x); r.right=std::max(r.right,p.x);
|
||||
r.top=std::min(r.top,p.y); r.bottom=std::max(r.bottom,p.y); }
|
||||
return r;
|
||||
}
|
||||
RECT r=region(m.from,m.to);
|
||||
for(auto p:m.points) {
|
||||
r.left=std::min(r.left,p.x); r.top=std::min(r.top,p.y);
|
||||
@@ -375,7 +446,7 @@ std::pair<POINT,POINT> thicknessSegment(const Mark &m)
|
||||
{
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto r=region(m.from,m.to);
|
||||
return {{r.right,r.top},{r.left,r.top}};
|
||||
return {rotateShapePoint(m,{r.right,r.top}),rotateShapePoint(m,{r.left,r.top})};
|
||||
}
|
||||
if(m.freehand && m.points.size()>1) {
|
||||
size_t index=(m.points.size()-1)/2;
|
||||
@@ -479,7 +550,10 @@ void drawMarks(HDC dc, const std::vector<Mark> &marks, const Mark *preview = nul
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
||||
auto r=region(m.from,m.to);
|
||||
if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid) {
|
||||
auto points=shapeOutline(m); Polyline(dc,points.data(),int(points.size()));
|
||||
}
|
||||
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
else Ellipse(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
SelectObject(dc,hollow);
|
||||
SelectObject(dc,previousPen); DeleteObject(arrowPen);
|
||||
@@ -594,7 +668,7 @@ void layout(HWND w, Capture &c)
|
||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||
r.bottom-=c.statusHeight;
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,488,524,572};
|
||||
static_assert(std::size(positions)==std::size(ToolIds));
|
||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||
{
|
||||
@@ -602,6 +676,9 @@ void layout(HWND w, Capture &c)
|
||||
MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96),
|
||||
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||
}
|
||||
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||
MoveWindow(GetDlgItem(w,28),MulDiv(612,dpi,96),MulDiv(6,dpi,96),
|
||||
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
||||
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||
@@ -611,13 +688,35 @@ void layout(HWND w, Capture &c)
|
||||
MoveWindow(GetDlgItem(w,34),MulDiv(480,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
||||
MoveWindow(GetDlgItem(w,35),MulDiv(516,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
||||
syncLineOptions(w,c);
|
||||
double ratio = std::min({1.0, double(std::max(1L,r.right-16))/c.image->width,
|
||||
c.canvas={0,c.panelHeight,r.right,std::max(LONG(c.panelHeight+1),r.bottom)};
|
||||
double ratio = c.zoom*std::min({1.0, double(std::max(1L,r.right-16))/c.image->width,
|
||||
double(std::max(1L,r.bottom-c.panelHeight-16))/c.image->height});
|
||||
int width = int(c.image->width*ratio), height = int(c.image->height*ratio);
|
||||
c.view = {(r.right-width)/2, c.panelHeight+(r.bottom-c.panelHeight-height)/2,
|
||||
(r.right+width)/2, c.panelHeight+(r.bottom-c.panelHeight+height)/2};
|
||||
int width = std::max(1,int(std::lround(c.image->width*ratio))), height = std::max(1,int(std::lround(c.image->height*ratio)));
|
||||
double maxX=std::max(0.0,(width-double(c.canvas.right-c.canvas.left))/2);
|
||||
double maxY=std::max(0.0,(height-double(c.canvas.bottom-c.canvas.top))/2);
|
||||
c.panX=std::clamp(c.panX,-maxX,maxX); c.panY=std::clamp(c.panY,-maxY,maxY);
|
||||
LONG x=LONG(std::lround((r.right-width)/2.0+c.panX));
|
||||
LONG y=LONG(std::lround(c.panelHeight+(r.bottom-c.panelHeight-height)/2.0+c.panY));
|
||||
c.view = {x,y,x+width,y+height};
|
||||
InvalidateRect(w, nullptr, FALSE);
|
||||
}
|
||||
void zoomCanvas(HWND w,Capture &c,double factor,POINT anchor,bool reset=false)
|
||||
{
|
||||
if(!c.editing || c.dragging || c.panning) return;
|
||||
double u=double(anchor.x-c.view.left)/std::max(1L,c.view.right-c.view.left);
|
||||
double v=double(anchor.y-c.view.top)/std::max(1L,c.view.bottom-c.view.top);
|
||||
double next=reset?1.0:std::clamp(c.zoom*factor,.1,16.0);
|
||||
if(next==c.zoom && !reset) return;
|
||||
c.zoom=next;
|
||||
if(reset) c.panX=c.panY=0;
|
||||
layout(w,c);
|
||||
if(!reset) {
|
||||
c.panX+=anchor.x-(c.view.left+u*(c.view.right-c.view.left));
|
||||
c.panY+=anchor.y-(c.view.top+v*(c.view.bottom-c.view.top));
|
||||
layout(w,c);
|
||||
}
|
||||
positionInlineText(c); updateStatus(c); InvalidateRect(w,nullptr,FALSE);
|
||||
}
|
||||
POINT imagePoint(Capture &c, POINT p, bool clamp = true)
|
||||
{
|
||||
POINT result{MulDiv(p.x-c.view.left,c.image->width,std::max(1L,c.view.right-c.view.left)),
|
||||
@@ -645,6 +744,10 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
||||
if(c.selected>=0)
|
||||
{
|
||||
const auto &m=c.marks[c.selected];
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto grip=screenshotShapeRotationHandle(m,double(c.unit)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
if(std::hypot(double(p.x-grip.x),double(p.y-grip.y))<=tolerance) return {c.selected,MarkPart::Rotation};
|
||||
}
|
||||
if(m.isText() || (m.isLine() && !m.label.empty())) {
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
auto grip=screenshotTextRotationHandle(text,double(c.unit*0.6)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
@@ -668,7 +771,7 @@ HCURSOR toolCursor(const Capture &c)
|
||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
||||
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||
int tool=c.highlight?19:c.straight?22:c.shape==ScreenshotShape::Trapezoid?37:c.shape==ScreenshotShape::Triangle?36:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||
return LoadCursorW(nullptr,IDC_CROSS);
|
||||
@@ -679,6 +782,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
LPCWSTR cursor=nullptr;
|
||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
||||
else if(hit.second>=MarkPart::Vertex0 && hit.second<=MarkPart::Vertex2) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
||||
const auto &mark=c.marks[hit.first];
|
||||
@@ -694,7 +798,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
void captureCursor(HWND w, Capture &c)
|
||||
{
|
||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||
bool inside=PtInRect(&c.view,p)!=FALSE;
|
||||
bool inside=PtInRect(&c.view,p) && PtInRect(&c.canvas,p);
|
||||
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
|
||||
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||
SetCursor(actionCursor(c,hit,inside || c.dragging));
|
||||
@@ -750,7 +854,18 @@ void drawHandles(HDC dc, const Capture &c)
|
||||
handle({m.from.x+m.radius,m.from.y});
|
||||
}
|
||||
else {
|
||||
handle(m.from); handle(m.to);
|
||||
if(m.shape==ScreenshotShape::Triangle) {
|
||||
auto vertices=shapeOutline(m);
|
||||
for(size_t i=0;i<3;++i) handle(vertices[i]);
|
||||
} else { handle(rotateShapePoint(m,m.from)); handle(rotateShapePoint(m,m.to)); }
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto r=region(m.from,m.to);
|
||||
auto top=point(rotateShapePoint(m,{(r.left+r.right)/2,r.top}));
|
||||
auto grip=screenshotShapeRotationHandle(m,double(c.unit)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
auto end=point(grip); MoveToEx(dc,top.x,top.y,nullptr); LineTo(dc,end.x,end.y);
|
||||
int radius=std::max(4,c.unit/8);
|
||||
Ellipse(dc,end.x-radius,end.y-radius,end.x+radius+1,end.y+radius+1);
|
||||
}
|
||||
auto [from,to]=thicknessSegment(m);
|
||||
auto mid=point({(from.x+to.x)/2,(from.y+to.y)/2});
|
||||
auto grip=thicknessHandle(c,m),end=point(grip);
|
||||
@@ -766,7 +881,8 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RECT r=item.rcItem;
|
||||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
||||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool)||
|
||||
(item.CtlID==36 && c.shape==ScreenshotShape::Triangle)||(item.CtlID==37 && c.shape==ScreenshotShape::Trapezoid);
|
||||
bool disabled=item.itemState&ODS_DISABLED;
|
||||
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
||||
bool hover=PtInRect(&r,mouse) && !disabled;
|
||||
@@ -780,7 +896,16 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
auto icon=(item.CtlID==36 || item.CtlID==37)?nullptr:item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
if(item.CtlID==36 || item.CtlID==37) {
|
||||
int cx=(r.left+r.right)/2,cy=(r.top+r.bottom)/2,s=c.iconSize/2;
|
||||
auto trianglePen=CreatePen(PS_SOLID,std::max(1,MulDiv(2,GetDpiForWindow(item.hwndItem),96)),
|
||||
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
||||
auto previous=SelectObject(dc,trianglePen);
|
||||
if(item.CtlID==36) { POINT vertices[]{{cx,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx,cy-s}}; Polyline(dc,vertices,4); }
|
||||
else { POINT vertices[]{{cx-s/2,cy-s},{cx+s/2,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx-s/2,cy-s}}; Polyline(dc,vertices,5); }
|
||||
SelectObject(dc,previous); DeleteObject(trianglePen);
|
||||
}
|
||||
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
||||
if(item.CtlID==20) {
|
||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
||||
@@ -816,6 +941,64 @@ void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard
|
||||
publish(c.app.window, clip);
|
||||
c.copied=true;
|
||||
}
|
||||
void saveToFile(HWND w, Capture &c, const std::filesystem::path &path)
|
||||
{
|
||||
finishInlineText(w,c,true);
|
||||
auto pixels=decodeImage(screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks));
|
||||
// GDI-rendered screenshot pixels are opaque; GDI does not maintain alpha.
|
||||
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||
auto bytes=encodePng(pixels);
|
||||
GUID id{};
|
||||
if(FAILED(CoCreateGuid(&id))) throw std::runtime_error("Cannot prepare screenshot output file");
|
||||
wchar_t unique[40]{}; StringFromGUID2(id,unique,(int)std::size(unique));
|
||||
auto temporary=path.parent_path()/(L".kswitcher-"+std::wstring(unique)+L".png");
|
||||
try {
|
||||
atomicWrite(temporary,bytes);
|
||||
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("Cannot save screenshot to the selected file");
|
||||
} catch(...) {
|
||||
std::error_code ignored; std::filesystem::remove(temporary,ignored); throw;
|
||||
}
|
||||
c.savedPath=path;
|
||||
c.copied=true; // Current edits have been exported, either to file or clipboard.
|
||||
}
|
||||
void save(HWND w, Capture &c, bool saveAs=false,
|
||||
BOOL (WINAPI *chooseFile)(LPOPENFILENAMEW)=GetSaveFileNameW)
|
||||
{
|
||||
if(!saveAs && !c.savedPath.empty()) {
|
||||
saveToFile(w,c,c.savedPath);
|
||||
return;
|
||||
}
|
||||
wchar_t filename[32768]{};
|
||||
auto proposed=c.savedPath.wstring();
|
||||
if(proposed.empty()) {
|
||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||
wchar_t name[100]{};
|
||||
swprintf_s(name,L"Screenshot-%04u%02u%02u-%02u%02u%02u.png",
|
||||
time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond);
|
||||
proposed=name;
|
||||
}
|
||||
if(proposed.size()>=std::size(filename)) throw std::runtime_error("Screenshot file path is too long");
|
||||
std::copy(proposed.begin(),proposed.end(),filename);
|
||||
OPENFILENAMEW dialog{sizeof(dialog)};
|
||||
dialog.hwndOwner=w;
|
||||
dialog.lpstrTitle=L"Save screenshot";
|
||||
dialog.lpstrFilter=L"PNG image (*.png)\0*.png\0\0";
|
||||
dialog.nFilterIndex=1;
|
||||
dialog.lpstrFile=filename;
|
||||
dialog.nMaxFile=(DWORD)std::size(filename);
|
||||
dialog.lpstrDefExt=L"png";
|
||||
dialog.Flags=OFN_OVERWRITEPROMPT|OFN_PATHMUSTEXIST|OFN_NOCHANGEDIR|OFN_EXPLORER;
|
||||
if(!chooseFile(&dialog)) {
|
||||
if(CommDlgExtendedError()) throw std::runtime_error("Cannot open the screenshot save dialog");
|
||||
return;
|
||||
}
|
||||
if(lower(std::filesystem::path(filename).extension().wstring())!=L".png")
|
||||
throw std::runtime_error("Choose a filename ending in .png for the screenshot");
|
||||
saveToFile(w,c,filename);
|
||||
c.statusText=L"Saved: "+c.savedPath.wstring();
|
||||
SendDlgItemMessageW(w,50,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
}
|
||||
void edit(HWND w, Capture &c)
|
||||
{
|
||||
auto r = region(c.start,c.end);
|
||||
@@ -836,14 +1019,17 @@ 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"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", 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 saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||
0,0,0,0,w,(HMENU)28,c.app.instance,nullptr);
|
||||
SetWindowSubclass(saveButton,toolHover,1,0);
|
||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
|
||||
for (size_t i=0;i<std::size(ToolIds);++i)
|
||||
@@ -933,7 +1119,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
break;
|
||||
case WM_DRAWITEM:
|
||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35 || wp==36 || wp==37) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||
break;
|
||||
case WM_CTLCOLORSTATIC:
|
||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||
@@ -1018,19 +1204,22 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
||||
}
|
||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||
if (LOWORD(wp)==28 || LOWORD(wp)==29) {
|
||||
save(w,*c,LOWORD(wp)==29); SetFocus(w); return 0;
|
||||
}
|
||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||
{
|
||||
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||
c->syncHistory();
|
||||
}
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
{
|
||||
c->pointer=LOWORD(wp)==27;
|
||||
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->straight=LOWORD(wp)==22;
|
||||
c->textTool=LOWORD(wp)==26;
|
||||
c->shape=LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||
c->shape=LOWORD(wp)==37?ScreenshotShape::Trapezoid:LOWORD(wp)==36?ScreenshotShape::Triangle:LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||
c->selected=-1; c->selectedLabel=false;
|
||||
c->highlight=LOWORD(wp)==19;
|
||||
c->freehand=LOWORD(wp)==21;
|
||||
@@ -1041,16 +1230,32 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
||||
}
|
||||
|
||||
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->strokeUsedConstraint=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;
|
||||
}
|
||||
if (c->editing && !c->dragging)
|
||||
{
|
||||
if((GetKeyState(VK_CONTROL)&0x8000) &&
|
||||
(wp==VK_OEM_PLUS || wp==VK_ADD || wp==VK_OEM_MINUS || wp==VK_SUBTRACT || wp=='0' || wp==VK_NUMPAD0)) {
|
||||
POINT center{(c->canvas.left+c->canvas.right)/2,(c->canvas.top+c->canvas.bottom)/2};
|
||||
zoomCanvas(w,*c,wp==VK_OEM_MINUS || wp==VK_SUBTRACT?1/1.25:1.25,center,wp=='0' || wp==VK_NUMPAD0);
|
||||
return 0;
|
||||
}
|
||||
if(wp>=VK_LEFT && wp<=VK_DOWN && c->selected>=0 && !c->inlineText) {
|
||||
POINT delta{wp==VK_LEFT?-1L:wp==VK_RIGHT?1L:0L,wp==VK_UP?-1L:wp==VK_DOWN?1L:0L};
|
||||
const auto &original=c->marks[c->selected];
|
||||
@@ -1067,6 +1272,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
beginMarkText(w,*c,c->selected); return 0;
|
||||
}
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='S') {
|
||||
save(w,*c,(GetKeyState(VK_SHIFT)&0x8000)!=0); SetFocus(w); return 0;
|
||||
}
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
|
||||
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
|
||||
@@ -1082,6 +1290,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||
if(wp=='O') SendMessageW(w,WM_COMMAND,24,0);
|
||||
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
||||
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
||||
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
||||
}
|
||||
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
||||
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
||||
@@ -1100,10 +1310,13 @@ 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)};
|
||||
if(PtInRect(&c->view,p)) {
|
||||
if(PtInRect(&c->view,p) && PtInRect(&c->canvas,p)) {
|
||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||
if(index>=0 && (c->marks[index].isText() || c->marks[index].isLine())) {
|
||||
c->selected=index; beginMarkText(w,*c,index);
|
||||
@@ -1113,8 +1326,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
case WM_LBUTTONDOWN:
|
||||
{
|
||||
if(c->panning) return 0;
|
||||
finishInlineText(w,*c,true);
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(c->editing && !PtInRect(&c->canvas,p)) return 0;
|
||||
auto hitPart=c->editing?markAt(*c,imagePoint(*c,p,false)).second:MarkPart::None;
|
||||
bool thickness=hitPart==MarkPart::Thickness || hitPart==MarkPart::Rotation;
|
||||
if (c->editing && !PtInRect(&c->view,p) && !thickness)
|
||||
@@ -1156,6 +1371,8 @@ 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->strokeUsedConstraint=(wp&MK_SHIFT)!=0;
|
||||
}
|
||||
c->dragging=true; SetCapture(w);
|
||||
}
|
||||
@@ -1163,8 +1380,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case WM_MOUSEWHEEL:
|
||||
{
|
||||
if(!c->editing || c->dragging) return 0;
|
||||
if(!c->editing || c->dragging || c->panning) return 0;
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)}; ScreenToClient(w,&p);
|
||||
if(!PtInRect(&c->canvas,p)) return 0;
|
||||
if(GET_KEYSTATE_WPARAM(wp)&MK_CONTROL) {
|
||||
c->wheelDelta=0; c->wheelTarget=-1;
|
||||
c->zoomWheelDelta+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||
int steps=c->zoomWheelDelta/WHEEL_DELTA; c->zoomWheelDelta%=WHEEL_DELTA;
|
||||
if(steps) zoomCanvas(w,*c,std::pow(1.25,steps),p);
|
||||
return 0;
|
||||
}
|
||||
c->zoomWheelDelta=0;
|
||||
if(!PtInRect(&c->view,p)) return 0;
|
||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||
if(index<0) { c->wheelDelta=0; c->wheelTarget=-1; return 0; }
|
||||
@@ -1184,8 +1410,27 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MBUTTONDOWN:
|
||||
{
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(c->editing && !c->dragging && PtInRect(&c->canvas,p)) {
|
||||
c->panning=true; c->panOrigin=p; SetCapture(w); SetCursor(LoadCursorW(nullptr,IDC_SIZEALL));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MBUTTONUP:
|
||||
if(c->panning) { c->panning=false; ReleaseCapture(); captureCursor(w,*c); }
|
||||
return 0;
|
||||
case WM_MOUSEMOVE:
|
||||
case WM_LBUTTONUP:
|
||||
if(c->panning) {
|
||||
if(msg==WM_MOUSEMOVE) {
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
c->panX+=p.x-c->panOrigin.x; c->panY+=p.y-c->panOrigin.y; c->panOrigin=p;
|
||||
layout(w,*c); positionInlineText(*c); SetCursor(LoadCursorW(nullptr,IDC_SIZEALL));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (c->dragging)
|
||||
{
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
@@ -1193,6 +1438,64 @@ 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));
|
||||
c->strokeUsedConstraint=c->strokeUsedConstraint || constrained;
|
||||
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;
|
||||
@@ -1230,6 +1533,25 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
}
|
||||
if(c->freehand && !c->strokeUsedConstraint &&
|
||||
!c->marks.back().fromNumber && !c->marks.back().toNumber &&
|
||||
!c->marks.back().fromLink.target && !c->marks.back().toLink.target) {
|
||||
ScreenshotShape recognized{};
|
||||
bool straightLine=false;
|
||||
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized,&straightLine);
|
||||
if(!improved.empty()) {
|
||||
c->remember(c->marks);
|
||||
auto &mark=c->marks.back();
|
||||
mark.points=std::move(improved);
|
||||
mark.from=mark.points.front(); mark.to=mark.points.back();
|
||||
if(straightLine) { mark.points.clear(); mark.freehand=false; }
|
||||
if(recognized!=ScreenshotShape::None) {
|
||||
mark.shape=recognized; mark.freehand=false; mark.ends=LineEnds::None;
|
||||
auto bounds=markBounds(mark);
|
||||
mark.from={bounds.left,bounds.top}; mark.to={bounds.right,bounds.bottom};
|
||||
}
|
||||
}
|
||||
}
|
||||
c->selected=int(c->marks.size())-1; c->selectedLabel=false;
|
||||
}
|
||||
else if (std::hypot(double(c->end.x-c->start.x),double(c->end.y-c->start.y))>=4)
|
||||
@@ -1240,6 +1562,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
c->marks.back().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
||||
c->marks.back().shape=c->shape;
|
||||
auto &mark=c->marks.back();
|
||||
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid) mark.points=shapeOutline(mark);
|
||||
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
@@ -1252,6 +1575,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
captureCursor(w,*c);
|
||||
return 0;
|
||||
case WM_CAPTURECHANGED:
|
||||
c->panning=false;
|
||||
if(c->dragging && c->part!=MarkPart::None)
|
||||
{
|
||||
c->marks=std::move(c->beforeDrag); c->part=MarkPart::None;
|
||||
@@ -1261,7 +1585,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (c->editing)
|
||||
{
|
||||
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
||||
limits->ptMinTrackSize = {MulDiv(650,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||
limits->ptMinTrackSize = {MulDiv(730,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -1311,7 +1635,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
auto previous=SelectObject(buffer,divider);
|
||||
MoveToEx(buffer,0,c->panelHeight-1,nullptr); LineTo(buffer,client.right,c->panelHeight-1);
|
||||
int dpi=GetDpiForWindow(w);
|
||||
for(int x:{368,452}) {
|
||||
for(int x:{440,524}) {
|
||||
MoveToEx(buffer,MulDiv(x,dpi,96),MulDiv(13,dpi,96),nullptr);
|
||||
LineTo(buffer,MulDiv(x,dpi,96),MulDiv(31,dpi,96));
|
||||
}
|
||||
@@ -1329,9 +1653,12 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
drawMarks(rendered.dc,visibleMarks,c->dragging && c->part==MarkPart::None?((c->highlight || c->freehand)?&c->stroke:&preview):nullptr);
|
||||
SetStretchBltMode(buffer,HALFTONE); SetBrushOrgEx(buffer,0,0,nullptr);
|
||||
int savedCanvas=SaveDC(buffer);
|
||||
IntersectClipRect(buffer,c->canvas.left,c->canvas.top,c->canvas.right,c->canvas.bottom);
|
||||
StretchBlt(buffer,c->view.left,c->view.top,c->view.right-c->view.left,c->view.bottom-c->view.top,
|
||||
rendered.dc,0,0,rendered.width,rendered.height,SRCCOPY);
|
||||
drawHandles(buffer,*c);
|
||||
RestoreDC(buffer,savedCanvas);
|
||||
BitBlt(dc,0,0,width,height,buffer,0,0,SRCCOPY);
|
||||
}
|
||||
}
|
||||
@@ -1376,6 +1703,11 @@ POINT screenshotTextRotationHandle(const ScreenshotMark &m,double clearance)
|
||||
auto r=plainTextBounds(m);
|
||||
return rotateTextPoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
||||
}
|
||||
POINT screenshotShapeRotationHandle(const ScreenshotMark &m,double clearance)
|
||||
{
|
||||
auto r=region(m.from,m.to);
|
||||
return rotateShapePoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
||||
}
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &m)
|
||||
{
|
||||
ScreenshotMark label{{0,0},{0,0},0};
|
||||
@@ -1455,23 +1787,23 @@ void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
||||
}
|
||||
POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link)
|
||||
{
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
if(m.isText() || (m.shape!=ScreenshotShape::None && m.points.empty())) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))};
|
||||
return m.isText()?rotateTextPoint(m,p):p;
|
||||
return m.isText()?rotateTextPoint(m,p):rotateShapePoint(m,p);
|
||||
}
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
double along=std::clamp(link.u,0.0,1.0)*segments;
|
||||
size_t i=std::min(size_t(along),segments-1); double t=along-i;
|
||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||
return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))};
|
||||
return rotateShapePoint(m,{LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))});
|
||||
}
|
||||
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
||||
{
|
||||
link={m.id,0,0};
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
if(m.isText() || (m.shape!=ScreenshotShape::None && m.points.empty())) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop;
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):rotateShapePoint(m,drop,-1);
|
||||
double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top);
|
||||
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
||||
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
||||
@@ -1491,6 +1823,7 @@ bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,Screen
|
||||
auto anchor=objectAnchor(m,link);
|
||||
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
||||
}
|
||||
drop=rotateShapePoint(m,drop,-1);
|
||||
double best=1e100;
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
for(size_t i=0;i<segments;++i) {
|
||||
@@ -1575,6 +1908,13 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||
}
|
||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
{
|
||||
if(m.shape==ScreenshotShape::Triangle) {
|
||||
auto vertices=shapeOutline(m);
|
||||
for(int i=0;i<3;++i)
|
||||
if(std::hypot(double(p.x-vertices[i].x),double(p.y-vertices[i].y))<=tolerance)
|
||||
return MarkPart(int(MarkPart::Vertex0)+i);
|
||||
}
|
||||
p=rotateShapePoint(m,p,-1);
|
||||
if(m.isText()) {
|
||||
auto r=plainTextBounds(m);
|
||||
auto local=rotateTextPoint(m,p,-1);
|
||||
@@ -1585,8 +1925,10 @@ MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
auto distance=[](POINT a, POINT b) { return std::hypot(double(a.x-b.x),double(a.y-b.y)); };
|
||||
if(!m.points.empty())
|
||||
{
|
||||
if(m.freehand && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
||||
if(m.freehand && distance(p,m.to)<=tolerance) return MarkPart::End;
|
||||
if(m.shape!=ScreenshotShape::Triangle) {
|
||||
if((m.freehand || m.shape!=ScreenshotShape::None) && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
||||
if((m.freehand || m.shape!=ScreenshotShape::None) && distance(p,m.to)<=tolerance) return MarkPart::End;
|
||||
}
|
||||
double reach=tolerance+std::clamp(m.thickness,m.isHighlight()?4:1,m.isHighlight()?100:30)/2.0;
|
||||
POINT from=m.points.front();
|
||||
for(auto to:m.points) {
|
||||
@@ -1640,7 +1982,32 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
{
|
||||
auto m=original;
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(part>=MarkPart::Vertex0 && part<=MarkPart::Vertex2 && m.shape==ScreenshotShape::Triangle) {
|
||||
if(m.points.empty()) { auto local=m; local.rotation=0; m.points=shapeOutline(local); }
|
||||
size_t index=size_t(int(part)-int(MarkPart::Vertex0)),fixed=(index+1)%3;
|
||||
auto fixedWorld=rotateShapePoint(m,m.points[fixed]);
|
||||
auto vertexWorld=rotateShapePoint(m,m.points[index]);
|
||||
POINT target{std::clamp(vertexWorld.x+position.x-origin.x,0L,LONG(width-1)),
|
||||
std::clamp(vertexWorld.y+position.y-origin.y,0L,LONG(height-1))};
|
||||
m.points[index]=rotateShapePoint(m,target,-1); m.points[3]=m.points[0];
|
||||
RECT bounds{m.points[0].x,m.points[0].y,m.points[0].x,m.points[0].y};
|
||||
for(size_t i=1;i<3;++i) {auto p=m.points[i]; bounds.left=std::min(bounds.left,p.x); bounds.right=std::max(bounds.right,p.x);
|
||||
bounds.top=std::min(bounds.top,p.y); bounds.bottom=std::max(bounds.bottom,p.y);}
|
||||
m.from={bounds.left,bounds.top}; m.to={bounds.right,bounds.bottom};
|
||||
auto shifted=rotateShapePoint(m,m.points[fixed]);
|
||||
LONG dx=fixedWorld.x-shifted.x,dy=fixedWorld.y-shifted.y;
|
||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||
for(auto &p:m.points) {p.x+=dx;p.y+=dy;}
|
||||
}
|
||||
else if(part==MarkPart::Rotation && m.shape!=ScreenshotShape::None) {
|
||||
double cx=(m.from.x+m.to.x)/2.0,cy=(m.from.y+m.to.y)/2.0;
|
||||
if(std::hypot(position.x-cx,position.y-cy)<1) return m;
|
||||
double a=std::atan2(origin.y-cy,origin.x-cx),b=std::atan2(position.y-cy,position.x-cx);
|
||||
m.rotation+=(b-a)*180/3.141592653589793;
|
||||
if(snapRotation) m.rotation=std::round(m.rotation/15)*15;
|
||||
m.rotation=std::remainder(m.rotation,360);
|
||||
}
|
||||
else if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
auto r=plainTextBounds(text);
|
||||
@@ -1694,13 +2061,34 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
}
|
||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||
{
|
||||
origin=rotateShapePoint(original,origin,-1);
|
||||
position=rotateShapePoint(original,position,-1);
|
||||
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
||||
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
||||
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
||||
endpoint=constrainScreenshotShape(m.shape,part==MarkPart::Start?m.to:m.from,endpoint,width,height);
|
||||
if(m.shape!=ScreenshotShape::None && !m.points.empty()) {
|
||||
if(m.from.x==m.to.x || m.from.y==m.to.y) return original;
|
||||
// Resize the entire recognized outline, not just a pencil endpoint.
|
||||
// Keep the opposite corner fixed, including when dragging across it.
|
||||
double oldWidth=original.to.x-original.from.x,oldHeight=original.to.y-original.from.y;
|
||||
for(auto &point:m.points) {
|
||||
double u=oldWidth?(point.x-original.from.x)/oldWidth:0;
|
||||
double v=oldHeight?(point.y-original.from.y)/oldHeight:0;
|
||||
point={LONG(std::lround(m.from.x+u*(m.to.x-m.from.x))),
|
||||
LONG(std::lround(m.from.y+v*(m.to.y-m.from.y)))};
|
||||
}
|
||||
}
|
||||
if(m.freehand && !m.points.empty()) (part==MarkPart::Start?m.points.front():m.points.back())=endpoint;
|
||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
|
||||
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
|
||||
LONG dx=fixedBefore.x-fixedAfter.x,dy=fixedBefore.y-fixedAfter.y;
|
||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||
for(auto &p:m.points) {p.x+=dx;p.y+=dy;}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
+4
-3
@@ -15,7 +15,7 @@ void showScreenshot(ScreenshotHost &);
|
||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||
enum class LineEnds { End, Start, Both, None };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid };
|
||||
struct ScreenshotOffset { double x, y; };
|
||||
struct ScreenshotLink {
|
||||
unsigned long long target{};
|
||||
@@ -40,7 +40,7 @@ struct ScreenshotMark {
|
||||
unsigned long long id{};
|
||||
ScreenshotLink fromLink, toLink;
|
||||
bool isText() const { return !text.empty(); }
|
||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
|
||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||
};
|
||||
class ScreenshotHistory {
|
||||
@@ -55,7 +55,7 @@ class ScreenshotHistory {
|
||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||
std::vector<ScreenshotMark> &, double tolerance);
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label, Vertex0, Vertex1, Vertex2 };
|
||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
||||
@@ -65,6 +65,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin
|
||||
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||
POINT screenshotShapeRotationHandle(const ScreenshotMark &, double clearance=32);
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||
// Render at source resolution; never reads or writes the system clipboard.
|
||||
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
||||
DWORD startScreenshotActivationListener();
|
||||
bool validScreenshotToken(const std::wstring &);
|
||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||
}
|
||||
|
||||
+32
-2
@@ -8,6 +8,22 @@
|
||||
namespace ks
|
||||
{
|
||||
using Json = nlohmann::json;
|
||||
std::wstring snippetId(const Snippet &snippet)
|
||||
{
|
||||
if (!snippet.id.empty()) return snippet.id;
|
||||
// Deterministic migration of pre-ID profiles on every existing device.
|
||||
std::wstring id = L"legacy-";
|
||||
const wchar_t *hex = L"0123456789abcdef";
|
||||
for (unsigned char c : utf8(lower(snippet.trigger))) { id += hex[c >> 4]; id += hex[c & 15]; }
|
||||
return id;
|
||||
}
|
||||
std::wstring newSnippetId()
|
||||
{
|
||||
GUID guid{}; wchar_t text[40]{};
|
||||
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create snippet ID.");
|
||||
StringFromGUID2(guid, text, 40);
|
||||
return text;
|
||||
}
|
||||
namespace
|
||||
{
|
||||
Bytes jsonBytes(const Json &j)
|
||||
@@ -107,8 +123,14 @@ void validateSettings(const Settings &s)
|
||||
s.maxImageMiB > 64)
|
||||
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
||||
std::set<std::wstring> seen;
|
||||
std::set<std::wstring> ids;
|
||||
for (const auto &id : s.snippetTombstones)
|
||||
if (id.empty() || id.size() > 600 || !ids.insert(id).second)
|
||||
throw std::runtime_error("Invalid snippet deletion record.");
|
||||
for (const auto &snippet : s.snippets)
|
||||
{
|
||||
auto id = snippetId(snippet);
|
||||
if (id.size() > 600 || !ids.insert(id).second) throw std::runtime_error("Duplicate snippet ID or deleted snippet.");
|
||||
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
||||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
||||
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
||||
@@ -203,7 +225,13 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
}
|
||||
if (j.contains("snippets"))
|
||||
for (auto &sn : j.at("snippets"))
|
||||
s.snippets.push_back({str(sn, "trigger"), str(sn, "text")});
|
||||
{
|
||||
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
|
||||
item.id = snippetId(item);
|
||||
s.snippets.push_back(std::move(item));
|
||||
}
|
||||
if (j.contains("snippetTombstones"))
|
||||
for (const auto &id : j.at("snippetTombstones")) s.snippetTombstones.push_back(wide(id.get<std::string>()));
|
||||
validateSettings(s);
|
||||
return s;
|
||||
}
|
||||
@@ -264,7 +292,9 @@ Bytes encodeSettings(const Settings &s)
|
||||
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
||||
j["snippets"] = Json::array();
|
||||
for (auto &sn : s.snippets)
|
||||
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}});
|
||||
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}});
|
||||
j["snippetTombstones"] = Json::array();
|
||||
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
|
||||
j["excludedApps"] = Json::array();
|
||||
for (auto &name : s.excluded)
|
||||
j["excludedApps"].push_back(utf8(name));
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#pragma once
|
||||
#include "screenshot.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace ks::stroke_detail {
|
||||
struct Point { double x, y; };
|
||||
inline double distance(Point a, Point b) { return std::hypot(a.x-b.x,a.y-b.y); }
|
||||
inline double length(const std::vector<Point> &p) {
|
||||
double total=0;
|
||||
for(size_t i=1;i<p.size();++i) total+=distance(p[i-1],p[i]);
|
||||
return total;
|
||||
}
|
||||
// Arc-length sampling makes fitting independent of mouse speed and bounds its cost.
|
||||
inline std::vector<Point> sample(const std::vector<Point> &p, size_t count) {
|
||||
std::vector<Point> out{p.front()};
|
||||
double step=length(p)/(count-1), traversed=0;
|
||||
size_t segment=1;
|
||||
for(size_t i=1;i+1<count;++i) {
|
||||
double target=i*step;
|
||||
while(segment+1<p.size() && traversed+distance(p[segment-1],p[segment])<target) {
|
||||
traversed+=distance(p[segment-1],p[segment]); ++segment;
|
||||
}
|
||||
double span=distance(p[segment-1],p[segment]);
|
||||
double t=span>0?(target-traversed)/span:0;
|
||||
out.push_back({p[segment-1].x+t*(p[segment].x-p[segment-1].x),
|
||||
p[segment-1].y+t*(p[segment].y-p[segment-1].y)});
|
||||
}
|
||||
out.push_back(p.back()); return out;
|
||||
}
|
||||
inline double segmentDistance(Point p, Point a, Point b) {
|
||||
double dx=b.x-a.x,dy=b.y-a.y,den=dx*dx+dy*dy;
|
||||
double t=den?std::clamp(((p.x-a.x)*dx+(p.y-a.y)*dy)/den,0.0,1.0):0;
|
||||
return distance(p,{a.x+t*dx,a.y+t*dy});
|
||||
}
|
||||
inline double cross(Point a, Point b, Point c) { return (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x); }
|
||||
}
|
||||
|
||||
namespace ks {
|
||||
// Returns a replacement path; an empty result means no visible improvement.
|
||||
// Report recognized geometry separately from a smoothed freehand path.
|
||||
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr,
|
||||
bool *straightLine=nullptr) {
|
||||
if(shape) *shape=ScreenshotShape::None;
|
||||
if(straightLine) *straightLine=false;
|
||||
using namespace stroke_detail;
|
||||
if(input.size()<3) return {};
|
||||
std::vector<Point> raw;
|
||||
for(auto p:input) if(raw.empty() || distance(raw.back(),{double(p.x),double(p.y)})>0)
|
||||
raw.push_back({double(p.x),double(p.y)});
|
||||
double total=length(raw);
|
||||
if(raw.size()>=3) {
|
||||
double span=distance(raw.front(),raw.back());
|
||||
if(span>=24 && total<=span*1.35) {
|
||||
double tolerance=std::clamp(span*.03,2.0,6.0),maximum=0,backtracking=0,previous=0;
|
||||
Point a=raw.front(),b=raw.back();
|
||||
// Check every original point so a short excursion cannot disappear in resampling.
|
||||
for(auto p:raw) {
|
||||
maximum=std::max(maximum,segmentDistance(p,a,b));
|
||||
double along=((p.x-a.x)*(b.x-a.x)+(p.y-a.y)*(b.y-a.y))/span;
|
||||
backtracking+=std::max(0.0,previous-along); previous=along;
|
||||
}
|
||||
double squared=0;
|
||||
for(auto p:sample(raw,64)) { double d=segmentDistance(p,a,b); squared+=d*d; }
|
||||
if(maximum<=tolerance && std::sqrt(squared/64)<=tolerance*.7 &&
|
||||
backtracking<=std::max(2.0,span*.02)) {
|
||||
if(straightLine) *straightLine=true;
|
||||
return {input.front(),input.back()};
|
||||
}
|
||||
}
|
||||
}
|
||||
if(raw.size()<5 || total<16) return {};
|
||||
auto points=sample(raw,128);
|
||||
double left=points[0].x,right=left,top=points[0].y,bottom=top;
|
||||
for(auto p:points) { left=std::min(left,p.x); right=std::max(right,p.x); top=std::min(top,p.y); bottom=std::max(bottom,p.y); }
|
||||
double scale=std::min(right-left,bottom-top);
|
||||
std::vector<Point> best;
|
||||
double bestScore=1e30;
|
||||
double gap=distance(raw.front(),raw.back());
|
||||
if(scale>=20 && gap<=scale*.5) {
|
||||
auto consider=[&](std::vector<Point> candidate, ScreenshotShape kind=ScreenshotShape::Rectangle) {
|
||||
bool oval=kind==ScreenshotShape::Circle || kind==ScreenshotShape::Ellipse;
|
||||
if(!oval && gap>scale*.22) return;
|
||||
double perimeter=length(candidate);
|
||||
if(total<perimeter*.82 || total>perimeter*1.25) return;
|
||||
auto probes=sample(candidate,128);
|
||||
double sum=0,maximum=0;
|
||||
auto measure=[&](const std::vector<Point> &a,const std::vector<Point> &b) {
|
||||
for(auto p:a) {
|
||||
double d=1e30;
|
||||
for(size_t i=1;i<b.size();++i) d=std::min(d,segmentDistance(p,b[i-1],b[i]));
|
||||
sum+=d*d; maximum=std::max(maximum,d);
|
||||
}
|
||||
};
|
||||
measure(points,candidate); measure(probes,points);
|
||||
double score=std::sqrt(sum/256);
|
||||
if(maximum<=std::max(3.0,scale*(oval?.22:.12)) && score<=std::max(1.5,scale*(oval?.085:.055)) && score<bestScore) {
|
||||
bestScore=score; best=std::move(candidate);
|
||||
if(shape) *shape=kind;
|
||||
}
|
||||
};
|
||||
// Fit oriented bounding boxes; testing angles also handles tilted ovals.
|
||||
for(int degrees=0;degrees<180;degrees+=5) {
|
||||
double angle=degrees*3.141592653589793/180,cs=std::cos(angle),sn=std::sin(angle);
|
||||
double x0=1e30,y0=x0,x1=-x0,y1=-x0;
|
||||
for(auto p:points) {
|
||||
double x=p.x*cs+p.y*sn,y=-p.x*sn+p.y*cs;
|
||||
x0=std::min(x0,x); x1=std::max(x1,x); y0=std::min(y0,y); y1=std::max(y1,y);
|
||||
}
|
||||
auto world=[&](double x,double y)->Point { return {x*cs-y*sn,x*sn+y*cs}; };
|
||||
consider({world(x0,y0),world(x1,y0),world(x1,y1),world(x0,y1),world(x0,y0)});
|
||||
double rx=(x1-x0)/2,ry=(y1-y0)/2;
|
||||
double cx=(x0+x1)/2,cy=(y0+y1)/2;
|
||||
std::vector<Point> local;
|
||||
for(auto p:points) local.push_back({p.x*cs+p.y*sn,-p.x*sn+p.y*cs});
|
||||
// Fit four sides to their supporting samples. Extremal bounds exaggerate
|
||||
// crooked corners and can unfairly make an oval score better than a box.
|
||||
double sides[]{x0,x1,y0,y1};
|
||||
bool supported=true;
|
||||
for(int iteration=0;iteration<6;++iteration) {
|
||||
double sums[4]{}; int counts[4]{};
|
||||
for(auto p:local) {
|
||||
double distances[]{std::abs(p.x-sides[0]),std::abs(p.x-sides[1]),
|
||||
std::abs(p.y-sides[2]),std::abs(p.y-sides[3])};
|
||||
auto side=std::min_element(distances,distances+4)-distances;
|
||||
sums[side]+=side<2?p.x:p.y; ++counts[side];
|
||||
}
|
||||
for(int side=0;side<4;++side) {
|
||||
if(counts[side]<10) { supported=false; break; }
|
||||
sides[side]=sums[side]/counts[side];
|
||||
}
|
||||
if(!supported) break;
|
||||
}
|
||||
if(supported && sides[1]-sides[0]>=16 && sides[3]-sides[2]>=16)
|
||||
consider({world(sides[0],sides[2]),world(sides[1],sides[2]),
|
||||
world(sides[1],sides[3]),world(sides[0],sides[3]),world(sides[0],sides[2])});
|
||||
// Fit the whole gesture instead of letting a single mouse wobble set the bounds.
|
||||
auto error=[&](double x,double y,double a,double b) {
|
||||
if(a<8 || b<8) return 1e30;
|
||||
double sum=0;
|
||||
for(auto p:local) {
|
||||
double u=(p.x-x)/a,v=(p.y-y)/b,r=std::hypot(u,v);
|
||||
double normal=std::hypot(u/a,v/b);
|
||||
double d=normal>1e-9?(r-1)*r/normal:std::min(a,b);
|
||||
sum+=d*d;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
double fit=error(cx,cy,rx,ry),step=std::max(rx,ry)*.08;
|
||||
for(int iteration=0;iteration<10;++iteration) {
|
||||
for(int parameter=0;parameter<4;++parameter) for(int sign:{-1,1}) {
|
||||
double values[]{cx,cy,rx,ry}; values[parameter]+=sign*step;
|
||||
double next=error(values[0],values[1],values[2],values[3]);
|
||||
if(next<fit) { fit=next; cx=values[0]; cy=values[1]; rx=values[2]; ry=values[3]; }
|
||||
}
|
||||
step*=.65;
|
||||
}
|
||||
// Require almost one complete turn, with little retracing. This admits a small
|
||||
// closing gap or overshoot but rejects C-shaped arcs, spirals and scribbles.
|
||||
double turn=0,travel=0;
|
||||
for(size_t i=1;i<local.size();++i) {
|
||||
double a=std::atan2((local[i-1].y-cy)/ry,(local[i-1].x-cx)/rx);
|
||||
double b=std::atan2((local[i].y-cy)/ry,(local[i].x-cx)/rx);
|
||||
double delta=std::remainder(b-a,2*3.141592653589793);
|
||||
turn+=delta; travel+=std::abs(delta);
|
||||
}
|
||||
if(std::abs(turn)<5.4 || std::abs(turn)>7.1 || travel-std::abs(turn)>.65) continue;
|
||||
// Near-circular gestures become true circles.
|
||||
bool circle=std::abs(rx-ry)<=std::max(rx,ry)*.12;
|
||||
if(circle) rx=ry=(rx+ry)/2;
|
||||
std::vector<Point> oval;
|
||||
for(int i=0;i<96;++i) {
|
||||
double t=i*2*3.141592653589793/96;
|
||||
oval.push_back(world(cx+rx*std::cos(t),cy+ry*std::sin(t)));
|
||||
}
|
||||
oval.push_back(oval.front()); consider(std::move(oval),circle?ScreenshotShape::Circle:ScreenshotShape::Ellipse);
|
||||
}
|
||||
auto sorted=points;
|
||||
std::sort(sorted.begin(),sorted.end(),[](Point a,Point b){return a.x<b.x || (a.x==b.x && a.y<b.y);});
|
||||
std::vector<Point> hull;
|
||||
for(auto p:sorted) {
|
||||
while(hull.size()>1 && cross(hull[hull.size()-2],hull.back(),p)<=0) hull.pop_back();
|
||||
hull.push_back(p);
|
||||
}
|
||||
size_t lower=hull.size();
|
||||
for(size_t i=sorted.size()-1;i-->0;) {
|
||||
auto p=sorted[i];
|
||||
while(hull.size()>lower && cross(hull[hull.size()-2],hull.back(),p)<=0) hull.pop_back();
|
||||
hull.push_back(p);
|
||||
}
|
||||
hull.pop_back();
|
||||
while(hull.size()>3) {
|
||||
if(hull.size()==4 && bestScore>scale*.035) {
|
||||
// Opposite edges must already be nearly parallel; retain their unequal
|
||||
// lengths and fit a shared direction instead of accepting any quadrilateral.
|
||||
for(size_t i=0;i<2;++i) {
|
||||
Point a=hull[i],b=hull[i+1],c=hull[(i+2)%4],d=hull[(i+3)%4];
|
||||
double ab=distance(a,b),cd=distance(c,d);
|
||||
if(std::min(ab,cd)<16 || std::min(ab,cd)/std::max(ab,cd)>.8) continue;
|
||||
Point u{(b.x-a.x)/ab,(b.y-a.y)/ab},v{(c.x-d.x)/cd,(c.y-d.y)/cd};
|
||||
if(u.x*v.x+u.y*v.y<.96) continue;
|
||||
double norm=std::hypot(u.x+v.x,u.y+v.y);
|
||||
Point axis{(u.x+v.x)/norm,(u.y+v.y)/norm};
|
||||
auto along=[&](Point p){return p.x*axis.x+p.y*axis.y;};
|
||||
auto across=[&](Point p){return -p.x*axis.y+p.y*axis.x;};
|
||||
double y0=(across(a)+across(b))/2,y1=(across(c)+across(d))/2;
|
||||
if(std::abs(y1-y0)<20) continue;
|
||||
auto fit=[&](Point p,double y)->Point {double x=along(p);return {x*axis.x-y*axis.y,x*axis.y+y*axis.x};};
|
||||
std::vector<Point> candidate{fit(a,y0),fit(b,y0),fit(c,y1),fit(d,y1),fit(a,y0)};
|
||||
consider(std::move(candidate),ScreenshotShape::Trapezoid);
|
||||
}
|
||||
}
|
||||
size_t remove=0; double area=1e30;
|
||||
for(size_t i=0;i<hull.size();++i) {
|
||||
double a=std::abs(cross(hull[(i+hull.size()-1)%hull.size()],hull[i],hull[(i+1)%hull.size()]));
|
||||
if(a<area) { area=a; remove=i; }
|
||||
}
|
||||
hull.erase(hull.begin()+remove);
|
||||
}
|
||||
if(hull.size()==3) { hull.push_back(hull.front()); consider(hull,ScreenshotShape::Triangle); }
|
||||
}
|
||||
if(best.empty()) {
|
||||
// A small spatial filter suppresses jitter without moving endpoints or flattening broad curves.
|
||||
best=sample(raw,size_t(std::clamp(std::ceil(total/2)+1,5.0,2048.0)));
|
||||
auto original=best;
|
||||
for(size_t i=1;i+1<best.size();++i)
|
||||
best[i]={(original[i-1].x+2*original[i].x+original[i+1].x)/4,
|
||||
(original[i-1].y+2*original[i].y+original[i+1].y)/4};
|
||||
}
|
||||
std::vector<POINT> result;
|
||||
for(auto p:best) {
|
||||
POINT q{LONG(std::lround(p.x)),LONG(std::lround(p.y))};
|
||||
if(result.empty() || result.back().x!=q.x || result.back().y!=q.y) result.push_back(q);
|
||||
}
|
||||
if(result.size()==input.size() && std::equal(result.begin(),result.end(),input.begin(),
|
||||
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;})) return {};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
#include "sync.h"
|
||||
#include "json.hpp"
|
||||
#include <winhttp.h>
|
||||
#include <wincred.h>
|
||||
#include <set>
|
||||
#include <algorithm>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
using Json = nlohmann::json;
|
||||
namespace
|
||||
{
|
||||
constexpr size_t Limit = 4 * 1024 * 1024;
|
||||
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
||||
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
||||
Json configJson(const SyncConfig &c)
|
||||
{
|
||||
return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)},
|
||||
{"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)},
|
||||
{"username", utf8(c.username)}};
|
||||
}
|
||||
std::wstring credentialName(const SyncConfig &c)
|
||||
{
|
||||
return L"KSwitcher.Sync/" + c.server + L"/" + c.authMount + L"/" + c.username;
|
||||
}
|
||||
bool safePath(const std::wstring &s)
|
||||
{
|
||||
if (s.empty() || s.size() > 512 || s.front() == '/' || s.back() == '/' ||
|
||||
s.find(L"//") != s.npos) return false;
|
||||
return s.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-/") == s.npos;
|
||||
}
|
||||
struct HttpHandle
|
||||
{
|
||||
HINTERNET h{};
|
||||
~HttpHandle() { if (h) WinHttpCloseHandle(h); }
|
||||
};
|
||||
class OpenBao final : public SyncProvider
|
||||
{
|
||||
SyncConfig config;
|
||||
SyncTransport transport;
|
||||
std::wstring token;
|
||||
Json response(unsigned status, const Bytes &result, bool missing)
|
||||
{
|
||||
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||
if (status == 404 && missing)
|
||||
{
|
||||
auto j = result.empty() ? Json::object() : parse(result);
|
||||
if (j.contains("data") && !j["data"].is_null())
|
||||
throw std::runtime_error("Remote profile is deleted. Restore it in OpenBao first.");
|
||||
return nullptr;
|
||||
}
|
||||
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
|
||||
if (status == 400) throw std::runtime_error("OpenBao rejected the write or its version changed. Sync again; check KV v2 configuration.");
|
||||
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
|
||||
return result.empty() ? Json::object() : parse(result);
|
||||
}
|
||||
Json request(const wchar_t *method, const std::wstring &path, const Json *body = nullptr, bool missing = false)
|
||||
{
|
||||
if (transport)
|
||||
{
|
||||
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
|
||||
return response(reply.status, reply.body, missing);
|
||||
}
|
||||
URL_COMPONENTS url{sizeof(url)};
|
||||
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
|
||||
if (!WinHttpCrackUrl(config.server.c_str(), 0, 0, &url)) throw std::runtime_error("Invalid server URL.");
|
||||
std::wstring host(url.lpszHostName, url.dwHostNameLength);
|
||||
HttpHandle session{WinHttpOpen(L"KSwitcher Sync/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)};
|
||||
if (!session.h || !WinHttpSetTimeouts(session.h, 5000, 5000, 5000, 5000))
|
||||
throw std::runtime_error("Cannot initialize sync connection.");
|
||||
HttpHandle connection{WinHttpConnect(session.h, host.c_str(), url.nPort, 0)};
|
||||
HttpHandle req{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr,
|
||||
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) : nullptr};
|
||||
DWORD redirects = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
|
||||
if (!req.h || !WinHttpSetOption(req.h, WINHTTP_OPTION_REDIRECT_POLICY, &redirects, sizeof(redirects)))
|
||||
throw std::runtime_error("Cannot initialize sync request.");
|
||||
std::wstring headers = L"Content-Type: application/json\r\n";
|
||||
if (!token.empty()) headers += L"X-Vault-Token: " + token + L"\r\n";
|
||||
std::string payload = body ? body->dump() : "";
|
||||
bool sent = WinHttpSendRequest(req.h, headers.c_str(), (DWORD)-1,
|
||||
payload.empty() ? WINHTTP_NO_REQUEST_DATA : payload.data(), (DWORD)payload.size(), (DWORD)payload.size(), 0);
|
||||
if (!payload.empty()) SecureZeroMemory(payload.data(), payload.size());
|
||||
if (!sent || !WinHttpReceiveResponse(req.h, nullptr))
|
||||
throw std::runtime_error("OpenBao connection failed. Check network, HTTPS certificate and server address.");
|
||||
DWORD status{}, length = sizeof(status);
|
||||
if (!WinHttpQueryHeaders(req.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &length, WINHTTP_NO_HEADER_INDEX))
|
||||
throw std::runtime_error("Cannot read OpenBao response.");
|
||||
Bytes result;
|
||||
ULONGLONG deadline = GetTickCount64() + 15000;
|
||||
for (;;)
|
||||
{
|
||||
unsigned char chunk[8192]; DWORD received{};
|
||||
if (GetTickCount64() > deadline || !WinHttpReadData(req.h, chunk, sizeof(chunk), &received))
|
||||
throw std::runtime_error("OpenBao response timed out.");
|
||||
if (!received) break;
|
||||
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||
result.insert(result.end(), chunk, chunk + received);
|
||||
}
|
||||
return response(status, result, missing);
|
||||
}
|
||||
public:
|
||||
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
|
||||
{
|
||||
validateSyncConfig(c);
|
||||
PCREDENTIALW credential{};
|
||||
if (!CredReadW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0, &credential))
|
||||
throw std::runtime_error("Sign in: no saved password for this server and user.");
|
||||
std::string password(reinterpret_cast<char *>(credential->CredentialBlob), credential->CredentialBlobSize);
|
||||
SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize);
|
||||
CredFree(credential);
|
||||
Json login{{"password", password}};
|
||||
SecureZeroMemory(password.data(), password.size());
|
||||
Json response;
|
||||
try { response = request(L"POST", L"/v1/auth/" + c.authMount + L"/login/" + c.username, &login); }
|
||||
catch (...) { auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size()); throw; }
|
||||
auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size());
|
||||
token = wide(response.at("auth").at("client_token").get<std::string>());
|
||||
if (token.empty() || token.find_first_of(L"\r\n") != token.npos)
|
||||
throw std::runtime_error("Invalid OpenBao authentication response.");
|
||||
}
|
||||
~OpenBao() override
|
||||
{
|
||||
if (token.empty()) return;
|
||||
// Sessions are scoped to a sync attempt; do not accumulate login tokens.
|
||||
try { request(L"POST", L"/v1/auth/token/revoke-self"); } catch (...) {}
|
||||
SecureZeroMemory(token.data(), token.size() * sizeof(wchar_t));
|
||||
}
|
||||
SyncDocument read() override
|
||||
{
|
||||
auto j = request(L"GET", L"/v1/" + config.mount + L"/data/" + config.path, nullptr, true);
|
||||
if (j.is_null()) return {};
|
||||
const auto &data = j.at("data");
|
||||
if (!data.at("metadata").value("deletion_time", std::string()).empty() ||
|
||||
data.at("metadata").value("destroyed", false))
|
||||
throw std::runtime_error("Remote profile is deleted or destroyed.");
|
||||
auto payload = data.at("data");
|
||||
if (payload.value("syncSchema", 0) != 1 && payload.value("syncSchema", 0) != 2) throw std::runtime_error("Unsupported remote sync format.");
|
||||
return {data.at("metadata").at("version").get<uint64_t>(), bytes(payload.at("settings"))};
|
||||
}
|
||||
uint64_t write(const Bytes &settings, uint64_t revision) override
|
||||
{
|
||||
Json body{{"options", {{"cas", revision}}}, {"data", {{"syncSchema", 2}, {"settings", parse(settings)}}}};
|
||||
auto response = request(L"POST", L"/v1/" + config.mount + L"/data/" + config.path, &body);
|
||||
return response.at("data").at("version").get<uint64_t>();
|
||||
}
|
||||
};
|
||||
}
|
||||
bool syncVisible(const std::filesystem::path &root) { return std::filesystem::exists(root / L"sync.json"); }
|
||||
SyncConfig loadSyncConfig(const std::filesystem::path &root)
|
||||
{
|
||||
SyncConfig c;
|
||||
if (!syncVisible(root)) return c;
|
||||
auto j = parse(readFile(root / L"sync.json", 16384));
|
||||
c.enabled = j.value("enabled", false);
|
||||
c.provider = wide(j.value("provider", "openbao"));
|
||||
c.server = wide(j.value("server", ""));
|
||||
c.mount = wide(j.value("mount", "kswitcher"));
|
||||
c.path = wide(j.value("path", "personal/settings"));
|
||||
c.authMount = wide(j.value("authMount", "userpass"));
|
||||
c.username = wide(j.value("username", ""));
|
||||
return c;
|
||||
}
|
||||
void validateSyncConfig(const SyncConfig &c)
|
||||
{
|
||||
if (c.provider != L"openbao") throw std::runtime_error("Unsupported sync provider.");
|
||||
URL_COMPONENTS url{sizeof(url)};
|
||||
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = url.dwUserNameLength = url.dwPasswordLength = (DWORD)-1;
|
||||
if (c.server.size() > 1024 || !WinHttpCrackUrl(c.server.c_str(), 0, 0, &url) ||
|
||||
url.nScheme != INTERNET_SCHEME_HTTPS || !url.dwHostNameLength || url.dwUserNameLength ||
|
||||
url.dwPasswordLength || url.dwExtraInfoLength ||
|
||||
(url.dwUrlPathLength && std::wstring(url.lpszUrlPath, url.dwUrlPathLength) != L"/"))
|
||||
throw std::runtime_error("Server must be an HTTPS origin, for example https://bao.example.com (no path or credentials).");
|
||||
bool userValid = !c.username.empty() && c.username.size() <= 256 && c.username != L"." && c.username != L".." &&
|
||||
c.username.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.@") == c.username.npos;
|
||||
if (!safePath(c.mount) || !safePath(c.path) || !safePath(c.authMount) || !userValid)
|
||||
throw std::runtime_error("Use letters, numbers, underscores and hyphens in paths and login; / is allowed only in paths.");
|
||||
if (credentialName(c).size() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH)
|
||||
throw std::runtime_error("Sync connection name is too long.");
|
||||
}
|
||||
void saveSyncConfig(const std::filesystem::path &root, const SyncConfig &c)
|
||||
{
|
||||
if (c.enabled) validateSyncConfig(c);
|
||||
atomicWrite(root / L"sync.json", bytes(configJson(c)));
|
||||
}
|
||||
void saveSyncPassword(const SyncConfig &c, const std::wstring &password)
|
||||
{
|
||||
validateSyncConfig(c);
|
||||
auto secret = utf8(password);
|
||||
if (secret.empty() || secret.size() > CRED_MAX_CREDENTIAL_BLOB_SIZE) throw std::runtime_error("Password is empty or too long.");
|
||||
auto target = credentialName(c);
|
||||
CREDENTIALW credential{}; credential.Type = CRED_TYPE_GENERIC; credential.TargetName = target.data();
|
||||
credential.UserName = const_cast<wchar_t *>(c.username.c_str());
|
||||
credential.CredentialBlob = reinterpret_cast<BYTE *>(secret.data());
|
||||
credential.CredentialBlobSize = (DWORD)secret.size(); credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
|
||||
bool ok = CredWriteW(&credential, 0) != 0;
|
||||
SecureZeroMemory(secret.data(), secret.size());
|
||||
if (!ok) throw std::runtime_error("Cannot save password in Windows Credential Manager.");
|
||||
}
|
||||
void forgetSyncPassword(const SyncConfig &c)
|
||||
{
|
||||
if (!CredDeleteW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0) && GetLastError() != ERROR_NOT_FOUND)
|
||||
throw std::runtime_error("Cannot remove saved sync password.");
|
||||
}
|
||||
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTransport transport)
|
||||
{
|
||||
return std::make_unique<OpenBao>(c, std::move(transport));
|
||||
}
|
||||
namespace { Json canonicalProfile(Json); }
|
||||
Bytes sharedSyncSettings(const Settings &s)
|
||||
{
|
||||
auto j = parse(encodeSettings(s));
|
||||
// Windows may enumerate the same installed layouts in a different order on
|
||||
// another PC. Binding order is not user intent (unlike the layout cycle).
|
||||
auto &bindings = j["layoutHotkeys"];
|
||||
std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) {
|
||||
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
||||
});
|
||||
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
||||
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
||||
return bytes(canonicalProfile(j));
|
||||
}
|
||||
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts)
|
||||
{
|
||||
auto j = parse(shared), original = parse(encodeSettings(local));
|
||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||
auto allowed = parse(sharedSyncSettings(local));
|
||||
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||
for (auto it = j.begin(); it != j.end(); ++it)
|
||||
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
|
||||
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
|
||||
// decodeSettings intentionally ignores hotkeys for missing layouts during import.
|
||||
// Sync must stop instead, otherwise the next upload would lose those bindings.
|
||||
std::set<std::string> installed;
|
||||
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
||||
for (const auto &id : j.at("layouts"))
|
||||
if (!installed.contains(id.get<std::string>())) throw std::runtime_error("Install the remote profile's keyboard layouts before syncing.");
|
||||
for (const auto &key : j.at("layoutHotkeys"))
|
||||
if (!installed.contains(key.at("layoutId").get<std::string>())) throw std::runtime_error("Install the remote profile's keyboard layouts before syncing.");
|
||||
original.update(j);
|
||||
return decodeSettings(bytes(original), layouts);
|
||||
}
|
||||
#include "sync_merge.inl"
|
||||
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
|
||||
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
|
||||
if (validate) validate(local);
|
||||
auto remote = provider.read();
|
||||
if (!remote.revision)
|
||||
{
|
||||
if (action != SyncAction::Upload) throw std::runtime_error("No remote profile. Choose Upload local to create it.");
|
||||
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
|
||||
}
|
||||
if (validate && action != SyncAction::Upload) validate(remote.settings);
|
||||
SyncMerge merged;
|
||||
if (action == SyncAction::Automatic) merged = mergeSyncProfile(base, local, remote.settings, choices);
|
||||
else
|
||||
{
|
||||
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : local)));
|
||||
merged = {selected, selected, selected, {}};
|
||||
}
|
||||
if (validate) { validate(merged.local); validate(merged.remote); }
|
||||
auto revision = remote.revision;
|
||||
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
|
||||
return {true, merged.local, revision,
|
||||
merged.conflicts.empty() ? L"Synchronized. Server version " + std::to_wstring(revision) + L"." :
|
||||
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
|
||||
merged.base, merged.conflicts};
|
||||
}
|
||||
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
|
||||
{
|
||||
Json j = Json::array();
|
||||
for (const auto &c : conflicts) j.push_back({{"key", c.key}, {"label", utf8(c.label)}, {"local", parse(c.local)}, {"remote", parse(c.remote)}});
|
||||
return bytes(j);
|
||||
}
|
||||
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &data)
|
||||
{
|
||||
std::vector<SyncConflict> result;
|
||||
for (const auto &c : parse(data)) result.push_back({c.at("key"), wide(c.at("label").get<std::string>()), bytes(c.at("local")), bytes(c.at("remote"))});
|
||||
return result;
|
||||
}
|
||||
Bytes encodeSyncChoices(const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
Json j = Json::array();
|
||||
for (const auto &c : choices) j.push_back({{"conflict", parse(encodeSyncConflicts({c.conflict})).at(0)}, {"useLocal", c.useLocal}});
|
||||
return bytes(j);
|
||||
}
|
||||
std::vector<SyncChoice> decodeSyncChoices(const Bytes &data)
|
||||
{
|
||||
auto j = parse(data);
|
||||
if (!j.is_array() || j.size() > 10000) throw std::runtime_error("Invalid conflict choices.");
|
||||
std::vector<SyncChoice> result;
|
||||
for (const auto &c : j) result.push_back({decodeSyncConflicts(bytes(Json::array({c.at("conflict")}))).at(0), c.at("useLocal").get<bool>()});
|
||||
return result;
|
||||
}
|
||||
std::wstring syncValueText(const Bytes &data)
|
||||
{
|
||||
auto j = parse(data);
|
||||
if (j.is_null()) return L"(not present)";
|
||||
if (tombstone(j)) return L"(deleted)";
|
||||
return wide(j.is_string() ? j.get<std::string>() : j.dump(2));
|
||||
}
|
||||
Bytes loadSyncBase(const std::filesystem::path &root, const SyncConfig &c)
|
||||
{
|
||||
auto path = root / L"sync-base.bin";
|
||||
if (!std::filesystem::exists(path)) return {};
|
||||
auto j = parse(unprotect(readFile(path, Limit + 65536)));
|
||||
return j.at("connection") == configJson(c) ? bytes(j.at("settings")) : Bytes{};
|
||||
}
|
||||
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
|
||||
{
|
||||
atomicWrite(root / L"sync-base.bin", protect(bytes({{"connection", configJson(c)}, {"settings", parse(base)}})));
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
#include "storage.h"
|
||||
#include <memory>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
struct SyncConfig
|
||||
{
|
||||
bool enabled{};
|
||||
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
||||
path = L"personal/settings", authMount = L"userpass", username;
|
||||
bool operator==(const SyncConfig &) const = default;
|
||||
};
|
||||
bool syncVisible(const std::filesystem::path &);
|
||||
SyncConfig loadSyncConfig(const std::filesystem::path &);
|
||||
void saveSyncConfig(const std::filesystem::path &, const SyncConfig &);
|
||||
void validateSyncConfig(const SyncConfig &);
|
||||
void saveSyncPassword(const SyncConfig &, const std::wstring &);
|
||||
void forgetSyncPassword(const SyncConfig &);
|
||||
struct SyncDocument { uint64_t revision{}; Bytes settings; };
|
||||
class SyncProvider
|
||||
{
|
||||
public:
|
||||
virtual ~SyncProvider() = default;
|
||||
virtual SyncDocument read() = 0;
|
||||
virtual uint64_t write(const Bytes &, uint64_t expectedRevision) = 0;
|
||||
};
|
||||
struct SyncHttpResponse { unsigned status{}; Bytes body; };
|
||||
using SyncTransport = std::function<SyncHttpResponse(const std::wstring &method, const std::wstring &path,
|
||||
const std::wstring &token, const Bytes &body)>;
|
||||
// An injectable transport permits protocol regression tests without a live vault.
|
||||
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &, SyncTransport transport = {});
|
||||
inline constexpr ULONGLONG SyncIntervalMs = 10 * 60 * 1000;
|
||||
struct SyncConflict
|
||||
{
|
||||
std::string key;
|
||||
std::wstring label;
|
||||
Bytes local, remote;
|
||||
};
|
||||
struct SyncChoice { SyncConflict conflict; bool useLocal{}; };
|
||||
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &);
|
||||
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &);
|
||||
Bytes encodeSyncChoices(const std::vector<SyncChoice> &);
|
||||
std::vector<SyncChoice> decodeSyncChoices(const Bytes &);
|
||||
std::wstring syncValueText(const Bytes &);
|
||||
struct SyncMerge
|
||||
{
|
||||
Bytes local, remote, base;
|
||||
std::vector<SyncConflict> conflicts;
|
||||
};
|
||||
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote,
|
||||
const std::vector<SyncChoice> &choices = {});
|
||||
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
|
||||
Bytes sharedSyncSettings(const Settings &);
|
||||
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &);
|
||||
enum class SyncAction { Automatic, Download, Upload };
|
||||
struct SyncResult
|
||||
{
|
||||
bool success{};
|
||||
Bytes settings;
|
||||
uint64_t revision{};
|
||||
std::wstring status;
|
||||
Bytes base;
|
||||
std::vector<SyncConflict> conflicts;
|
||||
};
|
||||
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
||||
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {});
|
||||
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
|
||||
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Three-way merge operates on individual values; unresolved values retain their
|
||||
// respective local/remote versions and their old common ancestor.
|
||||
namespace
|
||||
{
|
||||
Json normalizeProfile(Json j)
|
||||
{
|
||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||
for (auto &sn : j.at("snippets"))
|
||||
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
||||
return j;
|
||||
}
|
||||
Json treeProfile(const Json &source)
|
||||
{
|
||||
auto j = normalizeProfile(source);
|
||||
Json items = Json::object();
|
||||
for (const auto &sn : j.at("snippets"))
|
||||
{
|
||||
auto id = sn.at("id").get<std::string>();
|
||||
if (items.contains(id)) throw std::runtime_error("Duplicate snippet ID.");
|
||||
items[id] = sn;
|
||||
}
|
||||
for (const auto &id : j.at("snippetTombstones"))
|
||||
{
|
||||
auto key = id.get<std::string>();
|
||||
if (items.contains(key)) throw std::runtime_error("Duplicate or deleted snippet ID.");
|
||||
items[key] = {{"$deleted", true}};
|
||||
}
|
||||
j["snippets"] = items;
|
||||
j.erase("snippetTombstones");
|
||||
items = Json::object();
|
||||
for (const auto &binding : j.at("layoutHotkeys")) items[binding.at("layoutId").get<std::string>()] = binding.at("hotkey");
|
||||
j["layoutHotkeys"] = items;
|
||||
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
|
||||
{
|
||||
items = Json::object();
|
||||
for (const auto &item : j.at(key)) items[item.get<std::string>()] = true;
|
||||
j[key] = items;
|
||||
}
|
||||
for (auto &language : j.at("autoCorrectWords").items())
|
||||
{
|
||||
items = Json::object();
|
||||
for (const auto &word : language.value()) items[word.get<std::string>()] = true;
|
||||
language.value() = items;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
Json flatProfile(Json j)
|
||||
{
|
||||
Json snippets = Json::array(), deleted = Json::array();
|
||||
for (const auto &[id, item] : j.at("snippets").items())
|
||||
{
|
||||
if (item.is_null()) continue;
|
||||
if (item.value("$deleted", false)) deleted.push_back(id);
|
||||
else snippets.push_back(item);
|
||||
}
|
||||
j["snippets"] = snippets; j["snippetTombstones"] = deleted;
|
||||
Json bindings = Json::array();
|
||||
for (const auto &[id, hotkey] : j.at("layoutHotkeys").items())
|
||||
if (!hotkey.is_null()) bindings.push_back({{"layoutId", id}, {"hotkey", hotkey}});
|
||||
j["layoutHotkeys"] = bindings;
|
||||
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
|
||||
{
|
||||
Json list = Json::array();
|
||||
for (const auto &[item, present] : j.at(key).items()) if (present == true) list.push_back(item);
|
||||
j[key] = list;
|
||||
}
|
||||
for (auto &language : j.at("autoCorrectWords").items())
|
||||
{
|
||||
Json list = Json::array();
|
||||
if (language.value().is_object())
|
||||
for (const auto &[word, present] : language.value().items()) if (present == true) list.push_back(word);
|
||||
language.value() = list;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
std::string pointerKey(const std::string &s)
|
||||
{
|
||||
std::string out;
|
||||
for (char c : s) out += c == '~' ? "~0" : c == '/' ? "~1" : std::string(1, c);
|
||||
return out;
|
||||
}
|
||||
Json canonicalProfile(Json j) { return flatProfile(treeProfile(j)); }
|
||||
std::wstring fieldLabel(const std::string &key)
|
||||
{
|
||||
static const std::map<std::string, std::wstring> labels{
|
||||
{"toggleHotkey", L"Next layout shortcut"}, {"caseHotkey", L"Change case shortcut"},
|
||||
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
|
||||
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
|
||||
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
|
||||
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
||||
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
||||
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
||||
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
|
||||
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
||||
{"trigger", L"Trigger"}, {"text", L"Text"}};
|
||||
auto it = labels.find(key); return it == labels.end() ? wide(key) : it->second;
|
||||
}
|
||||
struct MergeNode { Json local, remote, base; };
|
||||
bool tombstone(const Json &j) { return j.is_object() && j.value("$deleted", false); }
|
||||
MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::string &path,
|
||||
const std::wstring &label, const std::vector<SyncChoice> &choices, std::vector<SyncConflict> &conflicts,
|
||||
const std::set<std::string> &forced)
|
||||
{
|
||||
if (l == r) return {l, l, l};
|
||||
for (const auto &choice : choices)
|
||||
if (choice.conflict.key == path && parse(choice.conflict.local) == l && parse(choice.conflict.remote) == r)
|
||||
{
|
||||
auto selected = choice.useLocal ? l : r;
|
||||
if (selected.is_null() && path.starts_with("/snippets/") && path.find('/', 10) == std::string::npos)
|
||||
selected = {{"$deleted", true}};
|
||||
return {selected, selected, selected};
|
||||
}
|
||||
if (forced.contains(path))
|
||||
{
|
||||
conflicts.push_back({path, label + L" (duplicate trigger or shortcut)", bytes(l), bytes(r)});
|
||||
return {l, r, b};
|
||||
}
|
||||
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
|
||||
if (!forcedChild && l == b) return {r, r, r};
|
||||
if (!forcedChild && r == b) return {l, l, l};
|
||||
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
|
||||
{
|
||||
MergeNode out{Json::object(), Json::object(), Json::object()};
|
||||
std::set<std::string> keys;
|
||||
for (const auto *j : {&b, &l, &r}) if (j->is_object()) for (auto it = j->begin(); it != j->end(); ++it) keys.insert(it.key());
|
||||
for (const auto &key : keys)
|
||||
{
|
||||
auto get = [&](const Json &j) { return j.is_object() && j.contains(key) ? j.at(key) : Json(); };
|
||||
auto childLabel = label.empty() ? fieldLabel(key) : label + L" / " + fieldLabel(key);
|
||||
if (path == "/snippets")
|
||||
{
|
||||
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
|
||||
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
|
||||
}
|
||||
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
|
||||
// Keep nulls in the ancestor so deletion conflicts survive restarts.
|
||||
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
conflicts.push_back({path, label, bytes(l), bytes(r)});
|
||||
return {l, r, b};
|
||||
}
|
||||
void collisionPaths(const Json &j, std::set<std::string> &paths)
|
||||
{
|
||||
std::map<std::wstring, std::string> triggers;
|
||||
for (const auto &[id, sn] : j.at("snippets").items())
|
||||
{
|
||||
if (sn.is_null() || tombstone(sn)) continue;
|
||||
auto path = "/snippets/" + pointerKey(id);
|
||||
auto key = lower(wide(sn.at("trigger").get<std::string>()));
|
||||
auto [it, inserted] = triggers.emplace(key, path);
|
||||
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||
}
|
||||
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
|
||||
auto add = [&](const Json &text, const std::string &path) {
|
||||
if (!text.is_string()) return;
|
||||
auto hotkey = Hotkey::parse(wide(text.get<std::string>()));
|
||||
if (!hotkey || !hotkey->key) return;
|
||||
auto [it, inserted] = shortcuts.emplace(std::make_pair(hotkey->modifiers, hotkey->key), path);
|
||||
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||
};
|
||||
for (const auto *key : {"toggleHotkey", "caseHotkey", "translitHotkey", "undoHotkey", "clipboardHotkey", "screenshotHotkey"}) add(j.at(key), std::string("/") + key);
|
||||
for (const auto &[id, key] : j.at("layoutHotkeys").items()) add(key, "/layoutHotkeys/" + pointerKey(id));
|
||||
}
|
||||
}
|
||||
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
|
||||
if (base.empty() && l != r) throw std::runtime_error("First connection: choose Download remote or Upload local.");
|
||||
auto b = base.empty() ? l : treeProfile(parse(base));
|
||||
SyncMerge result;
|
||||
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
||||
std::set<std::string> collisions;
|
||||
collisionPaths(merged.local, collisions); collisionPaths(merged.remote, collisions);
|
||||
if (!collisions.empty())
|
||||
{
|
||||
result.conflicts.clear();
|
||||
merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, collisions);
|
||||
}
|
||||
result.local = bytes(flatProfile(merged.local));
|
||||
result.remote = bytes(flatProfile(merged.remote));
|
||||
result.base = bytes(flatProfile(merged.base));
|
||||
return result;
|
||||
}
|
||||
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote)
|
||||
{
|
||||
auto result = mergeSyncProfile(base, local, remote);
|
||||
if (!result.conflicts.empty()) throw std::runtime_error("Conflicting setting: " + result.conflicts.front().key);
|
||||
return result.local;
|
||||
}
|
||||
+103
-269
@@ -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,7 +37,7 @@ 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{};
|
||||
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
||||
bool autoOptions{};
|
||||
};
|
||||
WindowLayout *windowLayoutState(HWND w)
|
||||
@@ -88,6 +91,11 @@ void arrangeWindow(HWND w)
|
||||
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)
|
||||
@@ -232,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}});
|
||||
@@ -311,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());
|
||||
}
|
||||
@@ -362,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);
|
||||
@@ -472,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);
|
||||
@@ -501,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)
|
||||
@@ -684,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);
|
||||
@@ -696,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)
|
||||
@@ -741,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];
|
||||
@@ -752,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;
|
||||
@@ -764,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:
|
||||
@@ -792,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)
|
||||
@@ -1239,6 +1298,7 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
#include "ui_sync.inl"
|
||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED)
|
||||
@@ -1275,6 +1335,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
}
|
||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||
if (id == 230)
|
||||
{
|
||||
collectLayout(w, v);
|
||||
@@ -1375,6 +1436,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);
|
||||
@@ -1382,12 +1451,16 @@ 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)
|
||||
{
|
||||
if (v->selection >= 0 && v->selection < (int)v->snippets.size())
|
||||
{
|
||||
v->snippets[v->selection] = {value(w, 302), value(w, 303)};
|
||||
auto &snippet = v->snippets[v->selection];
|
||||
if (snippet.id.empty()) snippet.id = snippetId(snippet);
|
||||
snippet.trigger = value(w, 302);
|
||||
snippet.text = value(w, 303);
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
@@ -1418,21 +1491,15 @@ 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)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->snippets.push_back({L";new", L""});
|
||||
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||
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;
|
||||
}
|
||||
@@ -1443,14 +1510,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)
|
||||
@@ -1570,248 +1637,7 @@ void showHistory(App &app, bool visible)
|
||||
}
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
LogClear = 606;
|
||||
void fitLogDetails(HWND list)
|
||||
{
|
||||
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
|
||||
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
|
||||
RECT client{};
|
||||
GetClientRect(list, &client);
|
||||
int remaining = client.right;
|
||||
for (int column = 0; column < 3; ++column)
|
||||
remaining -= ListView_GetColumnWidth(list, column);
|
||||
int width = std::max(scale(list, 120), remaining);
|
||||
if (ListView_GetColumnWidth(list, 3) != width)
|
||||
ListView_SetColumnWidth(list, 3, width);
|
||||
RemovePropW(list, L"FittingLogDetails");
|
||||
}
|
||||
LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
auto result = DefSubclassProc(w, msg, wp, lp);
|
||||
if (msg == WM_SIZE)
|
||||
fitLogDetails(w);
|
||||
if (msg == WM_NOTIFY && lp)
|
||||
{
|
||||
auto *header = reinterpret_cast<NMHEADERW *>(lp);
|
||||
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
|
||||
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
|
||||
header->iItem >= 0 && header->iItem < 3)
|
||||
fitLogDetails(w);
|
||||
}
|
||||
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
|
||||
return result;
|
||||
}
|
||||
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())
|
||||
{
|
||||
auto clip = textClip(copied);
|
||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||
app.ownSequence = writeClipboard(app.window, clip);
|
||||
}
|
||||
}
|
||||
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);
|
||||
fitLogDetails(list);
|
||||
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
|
||||
{
|
||||
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 && ((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, logListResizeProc, 3, 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);
|
||||
}
|
||||
auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.",
|
||||
0, 0, 16, 551, 720, 22);
|
||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->bottomLeft = hint;
|
||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||
arrangeWindow(w);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
#include "ui_log.inl"
|
||||
#else
|
||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||
#endif
|
||||
@@ -1873,6 +1699,7 @@ void showSettings(App &app, bool visible)
|
||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||
button(w, L"Sync...", 258, 448, 606, 90);
|
||||
button(w, L"Save", IDOK, 548, 606, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
@@ -1900,8 +1727,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));
|
||||
@@ -1931,11 +1759,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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
#include "ui_sync_conflicts.inl"
|
||||
struct SyncView { HWND parent; App *app; };
|
||||
SyncConfig syncFields(HWND w)
|
||||
{
|
||||
SyncConfig c;
|
||||
c.enabled = checked(w, 601);
|
||||
c.server = value(w, 602);
|
||||
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
||||
c.username = lower(value(w, 603)); c.mount = value(w, 605);
|
||||
c.path = value(w, 606); c.authMount = value(w, 607);
|
||||
return c;
|
||||
}
|
||||
LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||
auto *v = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||
try
|
||||
{
|
||||
if (msg == WM_TIMER)
|
||||
{
|
||||
value(w, 608, v->app->syncCommand());
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
|
||||
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||
if (id == 609)
|
||||
{
|
||||
auto c = loadSyncConfig(v->app->data);
|
||||
c.enabled = false;
|
||||
saveSyncConfig(v->app->data, c);
|
||||
forgetSyncPassword(c);
|
||||
check(w, 601, false);
|
||||
value(w, 604, L"");
|
||||
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
||||
KillTimer(w, 1);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK || id == 610 || id == 611)
|
||||
{
|
||||
if (id != IDOK && MessageBoxW(w, id == 610 ?
|
||||
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
|
||||
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
|
||||
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
||||
auto c = syncFields(w);
|
||||
if (id != IDOK && !c.enabled) throw std::runtime_error("Enable synchronization first.");
|
||||
if (c.enabled) validateSyncConfig(c);
|
||||
auto password = value(w, 604);
|
||||
try { if (!password.empty()) saveSyncPassword(c, password); }
|
||||
catch (...) { SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t)); throw; }
|
||||
SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t));
|
||||
value(w, 604, L"");
|
||||
saveSyncConfig(v->app->data, c);
|
||||
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
|
||||
SetTimer(w, 1, 1000, nullptr);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
// Never display parser exceptions containing connection file contents.
|
||||
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
|
||||
if (dynamic_cast<const std::runtime_error *>(&e))
|
||||
value(w, 608, wide(e.what()));
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
KillTimer(w, 1);
|
||||
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
|
||||
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
void showSyncOptions(HWND parent, App &app)
|
||||
{
|
||||
auto c = loadSyncConfig(app.data);
|
||||
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 640);
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
|
||||
checkbox(w, L"Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
|
||||
check(w, 601, c.enabled);
|
||||
label(w, L"Server (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
|
||||
label(w, L"Username", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
|
||||
label(w, L"Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
|
||||
label(w, L"Leave password blank to use the saved Windows credential.", 190, 166, 450);
|
||||
label(w, L"KV v2 mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
|
||||
label(w, L"Profile path", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
|
||||
label(w, L"Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
|
||||
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
|
||||
control(w, L"STATIC", L"", 0, 608, 18, 362, 622, 76);
|
||||
value(w, 608, app.syncCommand());
|
||||
button(w, L"Save / Sign in / Sync", IDOK, 18, 452, 200);
|
||||
button(w, L"Download remote", 610, 230, 452, 195);
|
||||
button(w, L"Upload local", 611, 437, 452, 203);
|
||||
button(w, L"Resolve individual conflicts...", 612, 18, 498, 300);
|
||||
label(w, L"Background check: every 10 minutes", 330, 502, 310);
|
||||
button(w, L"Disconnect / forget password", 609, 18, 550, 290);
|
||||
button(w, L"Close", IDCANCEL, 540, 550, 100);
|
||||
EnableWindow(parent, FALSE);
|
||||
SetTimer(w, 1, 1000, nullptr);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
struct SyncConflictView
|
||||
{
|
||||
HWND parent;
|
||||
App *app;
|
||||
std::vector<SyncConflict> conflicts;
|
||||
std::map<size_t, bool> choices;
|
||||
};
|
||||
void showConflictValues(HWND w, SyncConflictView *v)
|
||||
{
|
||||
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
|
||||
if (selected < 0 || selected >= (int)v->conflicts.size()) return;
|
||||
const auto &c = v->conflicts[selected];
|
||||
value(w, 621, syncValueText(c.local)); value(w, 622, syncValueText(c.remote));
|
||||
}
|
||||
LRESULT CALLBACK syncConflictsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||
auto *v = reinterpret_cast<SyncConflictView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||
try
|
||||
{
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 620 && HIWORD(wp) == LBN_SELCHANGE) { showConflictValues(w, v); return 0; }
|
||||
if (id == 623 || id == 624)
|
||||
{
|
||||
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
|
||||
if (selected < 0 || selected >= (int)v->conflicts.size()) return 0;
|
||||
v->choices[selected] = id == 623;
|
||||
auto text = v->conflicts[selected].label + (id == 623 ? L" [This PC]" : L" [Server]");
|
||||
SendDlgItemMessageW(w, 620, LB_DELETESTRING, selected, 0);
|
||||
SendDlgItemMessageW(w, 620, LB_INSERTSTRING, selected, (LPARAM)text.c_str());
|
||||
SendDlgItemMessageW(w, 620, LB_SETCURSEL, selected, 0);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK)
|
||||
{
|
||||
if (v->choices.empty()) throw std::runtime_error("Select a setting and choose This PC or Server first.");
|
||||
std::vector<SyncChoice> choices;
|
||||
for (const auto &[index, local] : v->choices) choices.push_back({v->conflicts.at(index), local});
|
||||
auto status = v->app->syncCommand(4, choices);
|
||||
value(v->parent, 608, status);
|
||||
if (status != L"Connecting to OpenBao...") throw std::runtime_error(utf8(status));
|
||||
DestroyWindow(w); return 0;
|
||||
}
|
||||
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e) { error(w, e); return 0; }
|
||||
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
|
||||
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
void showSyncConflicts(HWND parent, App &app)
|
||||
{
|
||||
auto conflicts = decodeSyncConflicts(app.syncConflictData());
|
||||
if (conflicts.empty()) { value(parent, 608, L"No pending conflicts. Run Sync to check for changes."); return; }
|
||||
HWND w = makeWindow(app, L"KSwitcher.SyncConflicts", L"Resolve individual settings", syncConflictsProc, 800, 560);
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
auto *v = new SyncConflictView{parent, &app, std::move(conflicts), {}};
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||
label(w, L"Choose a value for each conflicting setting. Other changes are already synchronized.", 18, 16, 760);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | WS_BORDER, 620, 18, 48, 760, 130);
|
||||
for (const auto &c : v->conflicts) SendDlgItemMessageW(w, 620, LB_ADDSTRING, 0, (LPARAM)c.label.c_str());
|
||||
label(w, L"This PC", 18, 190, 360); label(w, L"Server", 410, 190, 368);
|
||||
edit(w, 621, 18, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
edit(w, 622, 410, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
button(w, L"Use this PC for selected setting", 623, 18, 412, 368);
|
||||
button(w, L"Use server for selected setting", 624, 410, 412, 368);
|
||||
label(w, L"Unselected conflicts stay pending. Changed values will be checked again before saving.", 18, 454, 760);
|
||||
button(w, L"Apply choices", IDOK, 526, 488, 140);
|
||||
button(w, L"Cancel", IDCANCEL, 678, 488, 100);
|
||||
SendDlgItemMessageW(w, 620, LB_SETCURSEL, 0, 0); showConflictValues(w, v);
|
||||
EnableWindow(parent, FALSE); showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.14";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.26";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+25
-1
@@ -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'};'KSwitcher.Logs'{'KSwitcher log'}}
|
||||
$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
|
||||
@@ -127,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'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
#include <commctrl.h>
|
||||
@@ -14,6 +15,37 @@ struct InputRegressionAccess {
|
||||
static inline bool delivery = true;
|
||||
static inline int notifications = 0;
|
||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||
static void sampleConflicts(App &a) {
|
||||
a.conflictConnection=loadSyncConfig(a.data);
|
||||
a.pendingSyncConflicts={{"/autoCorrectMinLength",L"Minimum word length",{'4'},{'5'}}};
|
||||
}
|
||||
static bool staleSync(App &a, bool connectionChanged) {
|
||||
auto before=encodeSettings(a.settings);
|
||||
a.syncConnection=loadSyncConfig(a.data);
|
||||
a.syncSnapshot=before;
|
||||
if(connectionChanged) a.syncConnection.enabled=!a.syncConnection.enabled;
|
||||
else a.syncSnapshot.clear();
|
||||
auto remote=a.settings; remote.recent=!remote.recent;
|
||||
a.syncResult={true,sharedSyncSettings(remote),2,L"test"};
|
||||
a.syncWorker=std::thread([]{});
|
||||
a.syncRunning=true; a.syncDone=true;
|
||||
a.pollSync();
|
||||
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
||||
!std::filesystem::exists(a.data/L"sync-base.bin");
|
||||
}
|
||||
static bool syncBackoff(App &a) {
|
||||
a.syncRetryAfter=GetTickCount64()+15*60000;
|
||||
auto previous=a.settings;
|
||||
auto next=previous;
|
||||
next.toggle=next.casing=next.translit=next.undo=next.clipboard=next.screenshot=L"";
|
||||
next.direct.clear(); next.directIds.clear();
|
||||
// This harness deliberately has no global keyboard hook.
|
||||
a.applySettings(next);
|
||||
bool preserved=a.nextSync==a.syncRetryAfter;
|
||||
a.settings=previous;
|
||||
a.syncRetryAfter=0;
|
||||
return preserved;
|
||||
}
|
||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
static bool updateTimer(App &a) {
|
||||
@@ -594,6 +626,55 @@ 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);
|
||||
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
|
||||
DestroyWindow(app.settingsWindow);
|
||||
ks::saveSyncConfig(app.data,ks::SyncConfig{});
|
||||
ks::showSettings(app,false);
|
||||
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync button available after local opt-in");
|
||||
SendMessageW(app.settingsWindow,WM_COMMAND,258,0);
|
||||
auto syncWindow=FindWindowW(L"KSwitcher.Sync",nullptr);
|
||||
require(syncWindow!=nullptr,"private sync dialog opens");
|
||||
ShowWindow(syncWindow,SW_HIDE);
|
||||
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
|
||||
"sync password is masked");
|
||||
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
|
||||
ks::InputRegressionAccess::sampleConflicts(app);
|
||||
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
||||
require(conflictsWindow!=nullptr,"per-setting conflict dialog opens");
|
||||
ShowWindow(conflictsWindow,SW_HIDE);
|
||||
require(SendDlgItemMessageW(conflictsWindow,620,LB_GETCOUNT,0,0)==1,"conflict shown as separate setting");
|
||||
wchar_t conflictValue[20]{}; GetDlgItemTextW(conflictsWindow,621,conflictValue,20);
|
||||
require(std::wstring(conflictValue)==L"4","local conflict value shown");
|
||||
GetDlgItemTextW(conflictsWindow,622,conflictValue,20);
|
||||
require(std::wstring(conflictValue)==L"5","remote conflict value shown");
|
||||
for(int id:{620,621,622,623,624,IDOK,IDCANCEL}) {
|
||||
RECT control{},client{};
|
||||
GetWindowRect(GetDlgItem(conflictsWindow,id),&control);
|
||||
MapWindowPoints(nullptr,conflictsWindow,reinterpret_cast<POINT*>(&control),2);
|
||||
GetClientRect(conflictsWindow,&client);
|
||||
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||
"conflict controls fit dialog");
|
||||
}
|
||||
SendMessageW(conflictsWindow,WM_COMMAND,623,0);
|
||||
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
|
||||
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
|
||||
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
|
||||
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,IDOK,IDCANCEL}) {
|
||||
RECT control{},client{};
|
||||
GetWindowRect(GetDlgItem(syncWindow,id),&control);
|
||||
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
|
||||
GetClientRect(syncWindow,&client);
|
||||
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||
"sync controls fit dialog");
|
||||
}
|
||||
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
|
||||
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
|
||||
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
|
||||
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
||||
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
require(autoOptions!=nullptr,"auto-correction options open");
|
||||
@@ -728,16 +809,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);
|
||||
|
||||
+310
-9
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
@@ -21,6 +22,69 @@ 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;
|
||||
int droppedSelectionKeys = 0;
|
||||
bool temporaryClipboardExcluded = true;
|
||||
bool clipboardAccessTimedOut = false;
|
||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||||
(GetKeyState(VK_SHIFT) & 0x8000))
|
||||
{
|
||||
--droppedSelectionKeys;
|
||||
return 0;
|
||||
}
|
||||
// This bare EDIT control is our test fixture, not a full editor with
|
||||
// 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;
|
||||
@@ -37,13 +101,15 @@ 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';
|
||||
@@ -95,6 +161,9 @@ 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)
|
||||
@@ -143,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 3:
|
||||
require(text() == L"Hello World", "case conversion");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Case);
|
||||
break;
|
||||
case 4:
|
||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||||
prepare(L"OLDghbdtn", false);
|
||||
record(L"ghbdtn");
|
||||
app->transform(ks::Direct + ru);
|
||||
@@ -172,12 +243,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");
|
||||
@@ -235,6 +314,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);
|
||||
@@ -246,6 +326,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);
|
||||
@@ -281,7 +362,170 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 17:
|
||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||
[[fallthrough]];
|
||||
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);
|
||||
prepare(L"PuntoSwitcher ", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 44:
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 45:
|
||||
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 46:
|
||||
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"suffix recovery restores original clipboard");
|
||||
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||
record(L"snipping ");
|
||||
droppedSelectionKeys = 4;
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 47:
|
||||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||
finish();
|
||||
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)
|
||||
{
|
||||
@@ -353,7 +597,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case 22:
|
||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||
finish();
|
||||
stage = 30;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -376,6 +620,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()));
|
||||
@@ -391,9 +636,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);
|
||||
@@ -403,10 +661,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",
|
||||
@@ -431,7 +690,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
|
||||
@@ -477,8 +775,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();
|
||||
}
|
||||
|
||||
+374
-2
@@ -2,10 +2,69 @@
|
||||
// Including the implementation lets this integration test drive private editor state directly.
|
||||
#include "../src/screenshot.cpp"
|
||||
#include <iostream>
|
||||
int main()
|
||||
#include "stroke_improvement.h"
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
try {
|
||||
testStrokeImprovement();
|
||||
if(argc>1 && std::string(argv[1])=="--save-only") {
|
||||
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
|
||||
std::filesystem::create_directories(root);
|
||||
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
ks::Capture c(app);
|
||||
c.image=std::make_unique<ks::Surface>(320,240);
|
||||
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
|
||||
c.marks={ks::Mark{{40,40},{180,140},0}};
|
||||
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
|
||||
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
|
||||
auto path=root/L"Знімок.png";
|
||||
const auto clipboard=GetClipboardSequenceNumber();
|
||||
ks::saveToFile(nullptr,c,path);
|
||||
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
|
||||
auto actual=ks::decodeImage(encoded);
|
||||
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
|
||||
"PNG export preserves full resolution, annotations and opaque pixels");
|
||||
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
|
||||
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
|
||||
c.remember(c.marks); c.marks.clear();
|
||||
require(!c.copied,"editing after save marks changes unexported");
|
||||
ks::save(nullptr,c);
|
||||
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
|
||||
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
|
||||
c.copied=false;
|
||||
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
|
||||
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
|
||||
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
|
||||
return FALSE;
|
||||
});
|
||||
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
|
||||
auto another=root/L"Another.png";
|
||||
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
|
||||
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
|
||||
return TRUE;
|
||||
});
|
||||
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
|
||||
c.copied=false;
|
||||
bool failed=false;
|
||||
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
|
||||
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
|
||||
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
|
||||
"saving leaves no temporary files");
|
||||
bool invalid=false;
|
||||
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
|
||||
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
|
||||
});} catch(const std::exception &){invalid=true;}
|
||||
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
|
||||
CoUninitialize();
|
||||
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
|
||||
return 0;
|
||||
}
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||
@@ -24,6 +83,319 @@ 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");
|
||||
{
|
||||
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
|
||||
SendMessageW(window,WM_KEYDOWN,'G',0);
|
||||
require(c.shape==ks::ScreenshotShape::Triangle && c.arrow && !c.freehand,"G selects triangle drawing tool");
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
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,MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{80,80}); mouse(WM_LBUTTONUP,{220,180});
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Triangle && c.marks[0].points.size()==4,
|
||||
"triangle tool creates a closed three-vertex shape");
|
||||
auto triangle=c.marks[0];
|
||||
for(double angle:{0.0,37.0,90.0,-125.0}) for(int i=0;i<3;++i) {
|
||||
auto original=triangle; original.rotation=angle;
|
||||
auto vertices=ks::shapeOutline(original);
|
||||
auto part=ks::MarkPart(int(ks::MarkPart::Vertex0)+i);
|
||||
require(ks::hitScreenshotMark(original,vertices[i],4)==part,"each rotated triangle vertex has its own handle");
|
||||
POINT target{vertices[i].x+13,vertices[i].y-9};
|
||||
auto changed=ks::dragScreenshotMark(original,part,vertices[i],target,320,240);
|
||||
auto moved=ks::shapeOutline(changed);
|
||||
for(int j=0;j<3;++j) {
|
||||
auto expected=j==i?target:vertices[j];
|
||||
require(std::abs(moved[j].x-expected.x)<=2 && std::abs(moved[j].y-expected.y)<=2,
|
||||
"dragging one vertex preserves both other world vertices after rotation");
|
||||
}
|
||||
require(changed.points.front().x==changed.points.back().x && changed.points.front().y==changed.points.back().y,
|
||||
"triangle remains closed after editing any vertex");
|
||||
}
|
||||
auto vertices=ks::shapeOutline(triangle);
|
||||
mouse(WM_LBUTTONDOWN,vertices[0]);
|
||||
require(c.part==ks::MarkPart::Vertex0,"mouse selects apex vertex");
|
||||
mouse(WM_LBUTTONUP,{vertices[0].x+20,vertices[0].y+10});
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x+20 && c.marks[0].points[1].x==triangle.points[1].x,
|
||||
"mouse vertex drag changes only requested corner");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x,"undo restores triangle vertex");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x+20,"redo reapplies triangle vertex edit");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
require(GetDlgItem(window,37)!=nullptr,"trapezoid has a dedicated toolbar button");
|
||||
SendMessageW(window,WM_KEYDOWN,'B',0);
|
||||
require(c.shape==ks::ScreenshotShape::Trapezoid && c.arrow && !c.freehand,"B selects trapezoid tool");
|
||||
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Trapezoid && c.marks[0].points.size()==5 && !c.marks[0].isHighlight(),
|
||||
"trapezoid tool creates a closed shape with unequal parallel bases");
|
||||
auto trapezoid=c.marks[0];
|
||||
require(trapezoid.points[0].y==trapezoid.points[1].y && trapezoid.points[2].y==trapezoid.points[3].y &&
|
||||
trapezoid.points[1].x-trapezoid.points[0].x<trapezoid.points[2].x-trapezoid.points[3].x,"toolbar trapezoid has parallel unequal bases");
|
||||
mouse(WM_LBUTTONDOWN,trapezoid.to); mouse(WM_LBUTTONUP,{260,200});
|
||||
require(c.marks[0].to.x==260 && c.marks[0].points[0].y==c.marks[0].points[1].y,"trapezoid resizes as a shape");
|
||||
auto grip=ks::screenshotShapeRotationHandle(c.marks[0]);
|
||||
auto rotated=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,grip,{280,135},320,240);
|
||||
require(rotated.rotation!=0 && rotated.shape==ks::ScreenshotShape::Trapezoid,"trapezoid supports shape rotation");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo trapezoid resize");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
||||
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
SendMessageW(window,WM_COMMAND,21,0);
|
||||
}
|
||||
{
|
||||
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle})
|
||||
for(bool recognized:{false,true}) {
|
||||
if(kind==ks::ScreenshotShape::Triangle && !recognized) continue;
|
||||
ks::Mark shape{{100,90},{200,170},0}; shape.shape=kind; shape.ends=ks::LineEnds::None;
|
||||
if(kind==ks::ScreenshotShape::Circle) shape.to.y=190;
|
||||
if(recognized) shape.points=kind==ks::ScreenshotShape::Triangle?
|
||||
std::vector<POINT>{{150,90},{200,170},{100,170},{150,90}}:ks::shapeOutline(shape);
|
||||
auto grip=ks::screenshotShapeRotationHandle(shape);
|
||||
POINT center{(shape.from.x+shape.to.x)/2,(shape.from.y+shape.to.y)/2};
|
||||
POINT right{center.x+center.y-grip.y,center.y};
|
||||
auto rotated=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,right,320,240);
|
||||
require(std::abs(rotated.rotation-90)<.01 && rotated.from.x==shape.from.x && rotated.to.y==shape.to.y,
|
||||
"all native and recognized shapes rotate around their center");
|
||||
auto corner=ks::rotateShapePoint(rotated,rotated.to);
|
||||
require(ks::hitScreenshotMark(rotated,corner,4)==(kind==ks::ScreenshotShape::Triangle?ks::MarkPart::Vertex1:ks::MarkPart::End),"rotated resize handles remain hittable");
|
||||
auto fixed=ks::rotateShapePoint(rotated,rotated.from);
|
||||
auto resized=ks::dragScreenshotMark(rotated,ks::MarkPart::End,corner,{corner.x-15,corner.y+20},320,240);
|
||||
auto stillFixed=ks::rotateShapePoint(resized,resized.from);
|
||||
require(std::abs(stillFixed.x-fixed.x)<=1 && std::abs(stillFixed.y-fixed.y)<=1 && resized.rotation==90,
|
||||
"resizing rotated shapes keeps the opposite world corner fixed");
|
||||
auto snapped=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,{center.x+60,center.y-20},320,240,true);
|
||||
require(std::abs(std::remainder(snapped.rotation,15))<.01,"Shift snaps shape rotation to 15 degrees");
|
||||
auto cloned=ks::cloneScreenshotMark(rotated,{rotated},320,240);
|
||||
require(cloned.rotation==rotated.rotation && cloned.shape==kind,"clone preserves shape rotation");
|
||||
auto boundary=ks::shapeOutline(shape).front();
|
||||
std::vector<ks::Mark> linked{shape,{{20,20},boundary,0}};
|
||||
ks::linkScreenshotEndpoint(linked[1],false,boundary,linked,4);
|
||||
require(linked[1].toLink.target!=0,"connector attaches to shape before rotation");
|
||||
linked[0].rotation=90; ks::resolveScreenshotLinks(linked);
|
||||
auto expected=ks::rotateShapePoint(linked[0],boundary);
|
||||
require(std::abs(linked[1].to.x-expected.x)<=1 && std::abs(linked[1].to.y-expected.y)<=1,
|
||||
"connector follows rotated shape boundary");
|
||||
}
|
||||
ks::Mark shape{{100,90},{200,170},0}; shape.shape=ks::ScreenshotShape::Rectangle;
|
||||
c.marks={shape}; c.selected=0;
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
auto x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
auto 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,MAKELPARAM(x,y));
|
||||
};
|
||||
auto grip=ks::screenshotShapeRotationHandle(shape,double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"shape rotation grip is distinct from thickness grip");
|
||||
mouse(WM_LBUTTONDOWN,grip); mouse(WM_LBUTTONUP,{150+130-grip.y,130});
|
||||
require(std::abs(c.marks[0].rotation-90)<1,"mouse rotation updates selected shape");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].rotation==0,"undo restores original shape orientation");
|
||||
SendMessageW(window,WM_COMMAND,18,0); require(std::abs(c.marks[0].rotation-90)<1,"redo restores shape rotation");
|
||||
c.selected=0;
|
||||
ks::Surface rotatedPixels(320,240); PatBlt(rotatedPixels.dc,0,0,320,240,WHITENESS);
|
||||
auto rendered=ks::decodeImage(ks::screenshotClip(rotatedPixels.dc,320,240,c.marks));
|
||||
auto pixel=(100*320+190)*4;
|
||||
require(rendered.bgra[pixel+2]==GetRValue(shape.color) && rendered.bgra[pixel+1]==GetGValue(shape.color),
|
||||
"export renders rotated shape at its new boundary");
|
||||
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||
ks::zoomCanvas(window,c,2,center);
|
||||
grip=ks::screenshotShapeRotationHandle(c.marks[0],double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"rotation grip works on zoomed canvas");
|
||||
ks::zoomCanvas(window,c,1,center,true);
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
}
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
auto fit=c.view;
|
||||
SendMessageW(window,WM_KEYDOWN,VK_OEM_PLUS,0);
|
||||
require(c.zoom==1.25 && c.view.right-c.view.left>fit.right-fit.left,"Ctrl+plus zooms canvas");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SUBTRACT,0);
|
||||
require(std::abs(c.zoom-1)<.001,"Ctrl+numpad minus zooms out");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_ADD,0);
|
||||
SendMessageW(window,WM_KEYDOWN,VK_OEM_MINUS,0);
|
||||
require(std::abs(c.zoom-1)<.001,"both keyboard plus/minus variants work");
|
||||
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||
ks::zoomCanvas(window,c,4,center);
|
||||
POINT anchor{center.x+12,center.y+8},screen=anchor; ClientToScreen(window,&screen);
|
||||
auto before=ks::imagePoint(c,anchor,false); double zoom=c.zoom;
|
||||
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||
require(c.zoom==zoom,"partial wheel delta waits for a full step");
|
||||
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||
auto after=ks::imagePoint(c,anchor,false);
|
||||
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
|
||||
"Ctrl+wheel zoom keeps image point under cursor");
|
||||
require(!c.history.canUndo(),"zoom does not add image edits to undo history");
|
||||
auto view=c.view;
|
||||
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
|
||||
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));
|
||||
SendMessageW(window,WM_MBUTTONUP,0,MAKELPARAM(center.x+15,center.y+10));
|
||||
require(c.view.left==view.left+15 && c.view.top==view.top+10 && !c.panning,"middle drag pans enlarged canvas");
|
||||
keys[VK_CONTROL]=0; SetKeyboardState(keys);
|
||||
auto a=ks::imagePoint(c,center),b=ks::imagePoint(c,{center.x+30,center.y+20});
|
||||
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(center.x,center.y));
|
||||
SendMessageW(window,WM_LBUTTONUP,0,MAKELPARAM(center.x+30,center.y+20));
|
||||
require(c.marks.size()==1 && c.marks[0].from.x==a.x && c.marks[0].to.x==b.x && c.marks[0].to.y==b.y,
|
||||
"drawing after zoom and pan uses image coordinates");
|
||||
auto original=c.marks[0];
|
||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
SendMessageW(window,WM_KEYDOWN,'0',0);
|
||||
require(c.zoom==1 && c.panX==0 && c.panY==0 && c.marks[0].from.x==original.from.x && c.marks[0].to.x==original.to.x,
|
||||
"Ctrl+0 resets view without transforming annotations");
|
||||
ks::zoomCanvas(window,c,1e6,center); require(c.zoom==16,"zoom has upper bound");
|
||||
ks::zoomCanvas(window,c,1e-6,center); require(c.zoom==.1,"zoom has lower bound");
|
||||
SendMessageW(window,WM_KEYDOWN,'0',0); SetKeyboardState(savedKeys);
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
}
|
||||
{
|
||||
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,{30,190});
|
||||
for(int x=35;x<=200;x+=5) mouse(WM_MOUSEMOVE,{x,190+(x%10?2:-2)});
|
||||
auto original=c.stroke.points;
|
||||
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||
require(c.marks.size()==1,"improved stroke is one object");
|
||||
require(!c.marks[0].freehand && c.marks[0].points.empty() &&
|
||||
c.marks[0].shape==ks::ScreenshotShape::None && !c.marks[0].isHighlight(),
|
||||
"near-straight pencil stroke becomes an editable line on mouse release");
|
||||
require(c.marks[0].color==c.currentColor && c.marks[0].thickness==3 &&
|
||||
c.marks[0].style==c.lineStyle && c.marks[0].ends==c.freehandEnds,
|
||||
"line recognition preserves drawing style");
|
||||
auto improved=c.marks.back().points;
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.size()==1 && c.marks.back().freehand && c.marks.back().points.size()==original.size() &&
|
||||
std::equal(original.begin(),original.end(),c.marks.back().points.begin(),
|
||||
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;}),"first undo restores exact raw stroke");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.empty(),"second undo removes stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0); SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks.size()==1 && !c.marks.back().freehand && c.marks.back().points.size()==improved.size(),"redo restores improvement");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
mouse(WM_LBUTTONDOWN,{180,120});
|
||||
for(int i=1;i<=120;++i) {
|
||||
double a=i*2*3.141592653589793/120;
|
||||
mouse(WM_MOUSEMOVE,{LONG(std::lround(130+50*std::cos(a))),LONG(std::lround(120+50*std::sin(a)))});
|
||||
}
|
||||
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Circle &&
|
||||
!c.marks[0].freehand && !c.marks[0].isHighlight(),"recognized circle is a shape, not a pencil or highlight");
|
||||
auto circle=c.marks[0];
|
||||
require(ks::hitScreenshotMark(circle,circle.to,6)==ks::MarkPart::End,"recognized shape exposes bounding-box resize handle");
|
||||
mouse(WM_LBUTTONDOWN,circle.to);
|
||||
mouse(WM_MOUSEMOVE,{circle.to.x+30,circle.to.y+20});
|
||||
mouse(WM_LBUTTONUP,{circle.to.x+30,circle.to.y+20});
|
||||
require(c.marks[0].to.x>circle.to.x && c.marks[0].to.x-c.marks[0].from.x==c.marks[0].to.y-c.marks[0].from.y &&
|
||||
c.marks[0].points[0].x>circle.points[0].x,"dragging recognized circle handle scales the whole circle proportionally");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].shape==ks::ScreenshotShape::Circle && c.marks[0].to.x==circle.to.x,"undo restores shape size");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].freehand && c.marks[0].shape==ks::ScreenshotShape::None,"undo recognition restores editable pencil stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks[0].shape==ks::ScreenshotShape::Circle,"redo restores shape type");
|
||||
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle}) {
|
||||
auto shape=circle; shape.shape=kind;
|
||||
auto resized=ks::dragScreenshotMark(shape,ks::MarkPart::End,shape.to,{shape.to.x+50,shape.to.y+20},320,240);
|
||||
require(resized.points[0].x!=shape.points[0].x && resized.points[24].y!=shape.points[24].y,
|
||||
"shape resize transforms all outline points in both axes");
|
||||
auto moved=ks::cloneScreenshotMark(resized,{resized},320,240);
|
||||
require(moved.shape==kind && !moved.isHighlight(),"clone preserves recognized shape type");
|
||||
}
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
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 +448,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#pragma once
|
||||
inline void testStrokeImprovement() {
|
||||
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||
for(double angle:{0.0,.37,1.57079632679,2.8}) for(bool reverse:{false,true}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=20;++i) {
|
||||
double x=i*10,y=i==0 || i==20?0:(i%3)-1;
|
||||
p.push_back({LONG(std::lround(300+x*std::cos(angle)-y*std::sin(angle))),
|
||||
LONG(std::lround(300+x*std::sin(angle)+y*std::cos(angle)))});
|
||||
}
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
bool line=false; ks::ScreenshotShape shape{};
|
||||
auto result=ks::improveScreenshotStroke(p,&shape,&line);
|
||||
require(line && shape==ks::ScreenshotShape::None && result.size()==2 &&
|
||||
result.front().x==p.front().x && result.front().y==p.front().y &&
|
||||
result.back().x==p.back().x && result.back().y==p.back().y,
|
||||
"recognize noisy lines at arbitrary angles and preserve directed endpoints");
|
||||
}
|
||||
for(auto p:std::vector<std::vector<POINT>>{
|
||||
{{0,0},{50,1},{100,0}}, {{0,0},{0,0},{50,0},{100,0}}}) {
|
||||
bool line=false;
|
||||
require(ks::improveScreenshotStroke(p,nullptr,&line).size()==2 && line,
|
||||
"recognize sparsely sampled lines and repeated mouse positions");
|
||||
}
|
||||
for(auto p:std::vector<std::vector<POINT>>{
|
||||
{{0,0},{8,1},{16,0}}, {{0,0},{50,0},{40,0},{80,0},{100,0}},
|
||||
{{0,0},{25,8},{50,12},{75,8},{100,0}},
|
||||
{{0,0},{50,0},{51,10},{52,0},{100,0}}, {{0,0},{0,0},{0,0}}}) {
|
||||
bool line=true;
|
||||
ks::improveScreenshotStroke(p,nullptr,&line);
|
||||
require(!line,"do not straighten short strokes, retracing, curves, spikes or stationary input");
|
||||
}
|
||||
auto path=[](std::vector<POINT> corners) {
|
||||
std::vector<POINT> p;
|
||||
for(size_t i=1;i<corners.size();++i) for(int j=0;j<25;++j) {
|
||||
double t=j/25.0;
|
||||
p.push_back({LONG(std::lround(corners[i-1].x*(1-t)+corners[i].x*t)),
|
||||
LONG(std::lround(corners[i-1].y*(1-t)+corners[i].y*t))});
|
||||
}
|
||||
p.push_back(corners.back()); return p;
|
||||
};
|
||||
for(bool tilted:{false,true}) {
|
||||
auto p=path({{40,40},{240,40},{240,140},{40,140},{40,40}});
|
||||
if(tilted) for(auto &q:p) {double x=q.x,y=q.y;q={LONG(std::lround(x*.866-y*.5+100)),LONG(std::lround(x*.5+y*.866))};}
|
||||
for(size_t i=1;i+1<p.size();++i) p[i].y+=int(i%3)-1;
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()==5,"recognize noisy and tilted rectangles");
|
||||
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||
double bx=result[2].x-result[1].x,by=result[2].y-result[1].y;
|
||||
require(std::abs(ax*bx+ay*by)<300,"rectangle has perpendicular sides");
|
||||
}
|
||||
auto triangle=ks::improveScreenshotStroke(path({{130,30},{240,180},{30,180},{130,30}}));
|
||||
for(auto corners:std::vector<std::vector<POINT>>{
|
||||
{{435,172},{455,135},{481,76},{514,78},{567,78},{605,76},{631,103},{652,135},{681,171},{609,172},{539,174},{435,172}},
|
||||
{{531,311},{553,280},{582,235},{616,236},{672,233},{686,264},{693,287},{710,318},{640,314},{531,311}},
|
||||
{{70,40},{160,40},{220,150},{20,150},{70,40}}})
|
||||
for(double angle:{0.0,.6,2.2}) for(bool reverse:{false,true}) {
|
||||
auto p=path(corners);
|
||||
for(auto &q:p) { double x=q.x,y=q.y; q={LONG(std::lround(x*std::cos(angle)-y*std::sin(angle)+700)),
|
||||
LONG(std::lround(x*std::sin(angle)+y*std::cos(angle)+700))}; }
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
ks::ScreenshotShape kind{}; auto result=ks::improveScreenshotStroke(p,&kind);
|
||||
require(kind==ks::ScreenshotShape::Trapezoid && result.size()==5,"recognize hand-drawn and tilted trapezoids");
|
||||
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||
double bx=result[3].x-result[2].x,by=result[3].y-result[2].y;
|
||||
require(std::abs(ax*by-ay*bx)/(std::hypot(ax,ay)*std::hypot(bx,by))<.025,"recognized trapezoid bases are parallel");
|
||||
}
|
||||
// Approximate the reported skewed square, including its small closing gap.
|
||||
auto unevenBox=path({{662,297},{663,213},{659,199},{659,181},{691,181},{718,189},
|
||||
{753,195},{786,195},{775,217},{771,249},{771,289},{766,313},
|
||||
{745,311},{716,304},{689,300},{671,300}});
|
||||
for(double factor:{.5,1.0,2.5}) for(bool reverse:{false,true}) {
|
||||
auto p=unevenBox;
|
||||
for(auto &q:p) {q.x=LONG(std::lround((q.x-650)*factor+50));q.y=LONG(std::lround((q.y-170)*factor+50));}
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
ks::ScreenshotShape kind{};
|
||||
auto result=ks::improveScreenshotStroke(p,&kind);
|
||||
require(kind==ks::ScreenshotShape::Rectangle && result.size()==5,"uneven square must not become a circle");
|
||||
}
|
||||
require(triangle.size()==4,"recognize triangle");
|
||||
for(double ry:{50.0,90.0}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {double a=i*2*3.141592653589793/120;
|
||||
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+ry*std::sin(a)))});}
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()==97 && result.front().x==result.back().x && result.front().y==result.back().y,
|
||||
"recognize ellipse and circle as closed outlines");
|
||||
}
|
||||
auto open=path({{20,20},{200,20},{200,150},{60,150}});
|
||||
// Hand-traced approximations of the uneven loops in the reported screenshot.
|
||||
for(auto corners:std::vector<std::vector<POINT>>{
|
||||
{{424,408},{449,407},{474,418},{487,439},{488,461},{475,485},{453,488},{432,479},{412,458},{410,435},{416,418},{431,409}},
|
||||
{{352,474},{373,482},{389,501},{392,518},{378,529},{351,531},{331,519},{324,504},{326,486},{339,477},{352,474}},
|
||||
{{246,236},{221,242},{192,256},{166,271},{151,289},{154,303},{172,316},{195,315},{226,305},{246,291},{250,272},{252,252},{246,236}}}) {
|
||||
auto result=ks::improveScreenshotStroke(path(corners));
|
||||
require(result.size()==97,"recognize uneven hand-drawn loops from screenshot");
|
||||
}
|
||||
for(double size:{35.0,100.0,300.0}) for(double aspect:{.5,1.0}) for(int direction:{-1,1}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {
|
||||
double a=direction*(.3+i*5.85/120),r=1+.07*std::sin(3*a)+.035*std::cos(7*a);
|
||||
double x=size*r*std::cos(a),y=size*aspect*r*std::sin(a);
|
||||
p.push_back({LONG(std::lround(500+x*.866-y*.5)),LONG(std::lround(500+x*.5+y*.866))});
|
||||
}
|
||||
require(ks::improveScreenshotStroke(p).size()==97,"recognize wobbly tilted ovals with closing gaps at different sizes");
|
||||
}
|
||||
for(double turns:{.72,1.8}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {double a=i*turns*2*3.141592653589793/120;
|
||||
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+70*std::sin(a)))});}
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()!=97,"do not close broad arcs or repeated loops into ovals");
|
||||
}
|
||||
auto smoothed=ks::improveScreenshotStroke(open);
|
||||
require(smoothed.size()>5 && smoothed.front().x==20 && smoothed.back().x==60,"open outline stays open");
|
||||
auto scribble=path({{20,20},{200,150},{200,20},{20,150},{20,20}});
|
||||
require(ks::improveScreenshotStroke(scribble).size()>97,"crossed scribble is not a shape");
|
||||
require(ks::improveScreenshotStroke({{1,1},{1,1},{1,1},{1,1},{1,1}}).empty(),"stationary stroke unchanged");
|
||||
std::vector<POINT> jitter;
|
||||
for(int i=0;i<=100;++i) jitter.push_back({20+i*2,100+(i%2?2:-2)});
|
||||
auto smooth=ks::improveScreenshotStroke(jitter);
|
||||
double deviation=0; for(auto p:smooth) deviation+=std::abs(p.y-100);
|
||||
require(deviation/smooth.size()<1.5 && smooth.front().y==jitter.front().y && smooth.back().y==jitter.back().y,
|
||||
"smoothing reduces jitter and preserves endpoints");
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#include "sync.h"
|
||||
#include "json.hpp"
|
||||
#include <iostream>
|
||||
using namespace ks;
|
||||
using Json = nlohmann::json;
|
||||
namespace
|
||||
{
|
||||
int checks{};
|
||||
void require(bool condition, const char *message)
|
||||
{
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
++checks;
|
||||
}
|
||||
template<class F> void rejects(F f, const char *message)
|
||||
{
|
||||
bool rejected = false;
|
||||
try { f(); } catch (const std::exception &) { rejected = true; }
|
||||
require(rejected, message);
|
||||
}
|
||||
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
||||
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
||||
struct FakeProvider : SyncProvider
|
||||
{
|
||||
SyncDocument remote;
|
||||
int writes{};
|
||||
bool race{};
|
||||
SyncDocument read() override { return remote; }
|
||||
uint64_t write(const Bytes &b, uint64_t expected) override
|
||||
{
|
||||
if (race) ++remote.revision;
|
||||
if (expected != remote.revision) throw std::runtime_error("CAS conflict");
|
||||
++writes; remote = {remote.revision + 1, b}; return remote.revision;
|
||||
}
|
||||
};
|
||||
}
|
||||
int main()
|
||||
{
|
||||
auto root = std::filesystem::temp_directory_path() / (L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId()));
|
||||
try
|
||||
{
|
||||
std::filesystem::create_directories(root);
|
||||
require(!syncVisible(root), "hidden without opt-in file");
|
||||
require(!loadSyncConfig(root).enabled, "disabled by default");
|
||||
SyncConfig config; config.server = L"https://bao.example.com"; config.username = L"person"; config.enabled = true;
|
||||
validateSyncConfig(config);
|
||||
for (const auto *server : {L"http://bao.example.com", L"https://user:pass@bao.example.com", L"https://bao.example.com/x", L"https://bao.example.com?x=1"})
|
||||
{
|
||||
auto invalid = config; invalid.server = server;
|
||||
rejects([&] { validateSyncConfig(invalid); }, "reject unsafe endpoint");
|
||||
}
|
||||
auto invalid = config; invalid.path = L"../other";
|
||||
rejects([&] { validateSyncConfig(invalid); }, "reject traversal");
|
||||
invalid = config; invalid.provider = L"unknown";
|
||||
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
||||
saveSyncConfig(root, config);
|
||||
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
|
||||
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
||||
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
||||
Settings local;
|
||||
local.autostart = true; local.elevatedAutostart = true; local.tesseractPath = L"C:\\private\\tesseract.exe";
|
||||
local.history = false;
|
||||
local.snippets.push_back({L"test", L"private snippet"});
|
||||
auto base = sharedSyncSettings(local);
|
||||
Settings ordered = local, reordered = local;
|
||||
ordered.directIds = {L"00000409", L"00000422"}; ordered.direct = {L"Ctrl+1", L"Ctrl+2"};
|
||||
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
||||
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
||||
auto j = parse(base);
|
||||
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
|
||||
auto restored = restoreSyncSettings(base, local, {});
|
||||
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
|
||||
auto hostile = j; hostile["startWithSystem"] = false;
|
||||
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
|
||||
hostile = j; hostile["schemaVersion"] = 2;
|
||||
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
|
||||
hostile = j; hostile["layouts"] = Json::array({"00000422"});
|
||||
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "missing layouts block destructive normalization");
|
||||
auto left = j, right = j;
|
||||
left["convertLastWord"] = false; right["switchSystemLayout"] = false;
|
||||
auto merged = parse(mergeSyncSettings(base, bytes(left), bytes(right)));
|
||||
require(merged["convertLastWord"] == false && merged["switchSystemLayout"] == false, "merge independent edits");
|
||||
left = right = j; left["autoCorrectMinLength"] = 4; right["autoCorrectMinLength"] = 5;
|
||||
rejects([&] { mergeSyncSettings(base, bytes(left), bytes(right)); }, "same field conflict");
|
||||
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
||||
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
||||
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
|
||||
rejects([&] { mergeSyncSettings({}, bytes(left), base); }, "first sync needs explicit choice");
|
||||
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
||||
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
||||
{
|
||||
auto l = j, r = j;
|
||||
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||
l["convertLastWord"] = false; r["switchSystemLayout"] = false;
|
||||
auto plan = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||
require(plan.conflicts.size() == 1, "one conflict reported independently");
|
||||
require(parse(plan.local)["switchSystemLayout"] == false && parse(plan.remote)["convertLastWord"] == false,
|
||||
"nonconflicting changes reach both copies");
|
||||
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
||||
"unresolved scalar retains both values");
|
||||
require(parse(plan.base)["autoCorrectMinLength"] == j["autoCorrectMinLength"], "ancestor retained for unresolved setting");
|
||||
saveSyncBase(root, config, plan.base);
|
||||
auto again = mergeSyncProfile(loadSyncBase(root, config), plan.local, plan.remote);
|
||||
require(again.conflicts.size() == 1, "conflict survives restart checkpoint");
|
||||
auto choice = SyncChoice{plan.conflicts.front(), true};
|
||||
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {choice});
|
||||
require(resolved.conflicts.empty() && parse(resolved.remote)["autoCorrectMinLength"] == 4,
|
||||
"resolve only selected setting");
|
||||
require(parse(resolved.remote)["switchSystemLayout"] == false, "choice preserves independent server change");
|
||||
auto changed = parse(plan.remote); changed["autoCorrectMinLength"] = 6;
|
||||
require(mergeSyncProfile(plan.base, plan.local, bytes(changed), {choice}).conflicts.size() == 1,
|
||||
"stale conflict choice never overwrites new server value");
|
||||
FakeProvider partial; partial.remote = {7, bytes(r)};
|
||||
auto result = synchronize(partial, base, bytes(l), SyncAction::Automatic);
|
||||
require(result.success && result.conflicts.size() == 1 && partial.writes == 1,
|
||||
"partial synchronization commits independent fields with CAS");
|
||||
auto encoded = encodeSyncChoices({choice});
|
||||
require(decodeSyncChoices(encoded).front().conflict.key == choice.conflict.key, "choice IPC round trip");
|
||||
}
|
||||
{
|
||||
auto id = j["snippets"][0]["id"].get<std::string>();
|
||||
auto l = j, r = j;
|
||||
l["snippets"][0]["trigger"] = "renamed";
|
||||
r["snippets"][0]["text"] = "edited remotely";
|
||||
auto merged = parse(mergeSyncSettings(base, bytes(l), bytes(r)));
|
||||
require(merged["snippets"][0]["id"] == id && merged["snippets"][0]["trigger"] == "renamed" &&
|
||||
merged["snippets"][0]["text"] == "edited remotely", "stable ID merges rename and text edit");
|
||||
l = r = j; l["snippets"] = Json::array(); l["snippetTombstones"] = Json::array({id});
|
||||
auto deletion = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||
require(deletion.conflicts.empty() && parse(deletion.remote)["snippets"].empty(), "delete beats unchanged snippet");
|
||||
require(parse(deletion.remote)["snippetTombstones"] == Json::array({id}), "deletion marker retained");
|
||||
auto offline = mergeSyncProfile(base, bytes(r), deletion.remote);
|
||||
require(offline.conflicts.empty() && parse(offline.local)["snippets"].empty(), "offline copy cannot resurrect deletion");
|
||||
r["snippets"][0]["text"] = "offline edit";
|
||||
auto conflict = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||
require(conflict.conflicts.size() == 1, "delete versus edit is one snippet conflict");
|
||||
auto remove = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], true}});
|
||||
require(remove.conflicts.empty() && parse(remove.remote)["snippets"].empty(), "choose deletion");
|
||||
auto keep = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], false}});
|
||||
require(keep.conflicts.empty() && parse(keep.local)["snippetTombstones"].empty() &&
|
||||
parse(keep.local)["snippets"][0]["text"] == "offline edit", "choose edited snippet removes tombstone");
|
||||
require(mergeSyncProfile(base, bytes(l), bytes(l)).conflicts.empty(), "both delete converges");
|
||||
auto legacy = j; legacy.erase("snippetTombstones"); legacy["snippets"][0].erase("id");
|
||||
auto migrated = parse(mergeSyncSettings(bytes(legacy), base, bytes(legacy)));
|
||||
require(migrated["snippets"][0]["id"] == id, "legacy profiles migrate to identical IDs");
|
||||
auto roundtrip = decodeSettings(encodeSettings(restored), {});
|
||||
require(snippetId(roundtrip.snippets.front()) == wide(id), "ID survives settings serialization");
|
||||
require(newSnippetId() != newSnippetId(), "new snippet IDs are unique");
|
||||
}
|
||||
{
|
||||
auto l = j, r = j;
|
||||
l["snippets"].push_back({{"id", "new-a"}, {"trigger", "same"}, {"text", "A"}});
|
||||
r["snippets"].push_back({{"id", "new-b"}, {"trigger", "same"}, {"text", "B"}});
|
||||
auto collision = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||
require(collision.conflicts.size() == 2, "duplicate new triggers expose individual choices");
|
||||
auto resolved = mergeSyncProfile(collision.base, collision.local, collision.remote,
|
||||
{{collision.conflicts[0], true}, {collision.conflicts[1], true}});
|
||||
require(resolved.conflicts.empty() && parse(resolved.remote)["snippets"].size() == 2, "choose one of colliding additions");
|
||||
l = r = j; l["toggleHotkey"] = "Ctrl+Alt+F11"; r["caseHotkey"] = "Ctrl+Alt+F11";
|
||||
require(mergeSyncProfile(base, bytes(l), bytes(r)).conflicts.size() == 2, "cross-field shortcut collisions exposed");
|
||||
l = r = j; l["excludedApps"].push_back("local-app"); r["excludedApps"].push_back("remote-app");
|
||||
require(parse(mergeSyncSettings(base, bytes(l), bytes(r)))["excludedApps"].size() == j["excludedApps"].size() + 2,
|
||||
"independent exclusion edits merge");
|
||||
}
|
||||
FakeProvider provider;
|
||||
rejects([&] { synchronize(provider, {}, base, SyncAction::Automatic); }, "empty server never auto-populated");
|
||||
auto result = synchronize(provider, {}, base, SyncAction::Upload);
|
||||
require(result.revision == 1 && provider.writes == 1, "initial upload CAS zero");
|
||||
result = synchronize(provider, base, base, SyncAction::Automatic);
|
||||
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
|
||||
result = synchronize(provider, base, bytes(left), SyncAction::Download);
|
||||
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
|
||||
provider.race = true;
|
||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
||||
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
||||
provider.race = false;
|
||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
||||
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
||||
require(provider.writes == 1, "invalid data not uploaded");
|
||||
saveSyncBase(root, config, base);
|
||||
require(parse(loadSyncBase(root, config)) == j, "encrypted baseline round trip");
|
||||
auto other = config; other.path = L"other/settings";
|
||||
require(loadSyncBase(root, other).empty(), "baseline bound to connection");
|
||||
auto encrypted = readFile(root / L"sync-base.bin", 1024 * 1024);
|
||||
std::string disk(encrypted.begin(), encrypted.end());
|
||||
require(disk.find("private snippet") == disk.npos, "baseline encrypted on disk");
|
||||
// Exercise real Credential Manager with an isolated test target, never a user endpoint.
|
||||
config.username = L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId());
|
||||
saveSyncPassword(config, L"test-only-password");
|
||||
int logins{}, revokes{}, reads{}, writes{};
|
||||
unsigned readStatus = 200, writeStatus = 200;
|
||||
bool deleted = false;
|
||||
auto transport = [&](const std::wstring &method, const std::wstring &path,
|
||||
const std::wstring &token, const Bytes &body) -> SyncHttpResponse {
|
||||
if (path == L"/v1/auth/userpass/login/" + config.username)
|
||||
{
|
||||
require(method == L"POST" && token.empty(), "login route and no bearer token");
|
||||
require(parse(body)["password"] == "test-only-password", "password sourced from credential manager");
|
||||
++logins;
|
||||
return {200, bytes({{"auth", {{"client_token", "test-token"}}}})};
|
||||
}
|
||||
require(token == L"test-token", "authenticated requests use returned token");
|
||||
if (path == L"/v1/auth/token/revoke-self")
|
||||
{
|
||||
require(method == L"POST", "revoke method"); ++revokes; return {204, {}};
|
||||
}
|
||||
require(path == L"/v1/kswitcher/data/personal/settings", "KV v2 data route");
|
||||
if (method == L"GET")
|
||||
{
|
||||
++reads;
|
||||
if (readStatus != 200 && !deleted) return {readStatus, bytes({{"errors", Json::array()}})};
|
||||
return {readStatus, bytes({{"data", {
|
||||
{"metadata", {{"version", 7}, {"deletion_time", deleted ? "2026-01-01" : ""}, {"destroyed", false}}},
|
||||
{"data", {{"syncSchema", 1}, {"settings", j}}}}}})};
|
||||
}
|
||||
require(method == L"POST", "write method");
|
||||
auto request = parse(body);
|
||||
require(request["options"]["cas"] == 7, "write includes exact CAS revision");
|
||||
require(request["data"]["syncSchema"] == 2 && request["data"]["settings"] == j, "versioned settings envelope");
|
||||
++writes;
|
||||
return {writeStatus, bytes({{"data", {{"version", 8}}}})};
|
||||
};
|
||||
{
|
||||
auto bao = makeSyncProvider(config, transport);
|
||||
auto doc = bao->read();
|
||||
require(doc.revision == 7 && parse(doc.settings) == j, "read protocol response");
|
||||
require(bao->write(base, 7) == 8, "write protocol response");
|
||||
readStatus = 404;
|
||||
require(bao->read().revision == 0, "missing profile");
|
||||
deleted = true;
|
||||
rejects([&] { bao->read(); }, "soft deleted is not treated as absent");
|
||||
deleted = false; readStatus = 403;
|
||||
rejects([&] { bao->read(); }, "permission denied is not absent");
|
||||
readStatus = 302;
|
||||
rejects([&] { bao->read(); }, "redirect response rejected");
|
||||
writeStatus = 400;
|
||||
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
|
||||
}
|
||||
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
|
||||
forgetSyncPassword(config); forgetSyncPassword(config);
|
||||
++checks;
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << checks << " sync checks passed\n";
|
||||
return 0;
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
std::cerr << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -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") ==
|
||||
@@ -148,6 +192,26 @@ int main(int argc, char **argv)
|
||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||
ks::Run suffixRun;
|
||||
suffixRun.keys = L"snipping ";
|
||||
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
|
||||
"recent Cyrillic suffix matches physical keys across layouts");
|
||||
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
|
||||
"recent English suffix matches physical keys");
|
||||
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
|
||||
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
|
||||
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
|
||||
"suffix recovery rejects unrelated, empty and complete ranges");
|
||||
suffixRun.overflow = true;
|
||||
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
|
||||
"suffix recovery rejects overflowed tracking");
|
||||
suffixRun.overflow = false;
|
||||
suffixRun.keys = L"LinkedIn ";
|
||||
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
|
||||
"ChatGPT partial CamelCase selection matches tracked physical suffix");
|
||||
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
|
||||
suffixRun.match(L"лувШт ", layouts, en) < 0,
|
||||
"CamelCase conversion still requires the complete tracked text after recovery");
|
||||
ks::AutoCorrector corrector;
|
||||
ks::Settings autoSettings;
|
||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||
@@ -247,6 +311,14 @@ int main(int argc, char **argv)
|
||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||
options.cycle = {L"en", L"uk", L"ru"};
|
||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||
auto current = supplemental.suggest(L"Gjnjxys ", 0, layouts, options);
|
||||
require(current && current->text == L"Поточні " && current->target == 2,
|
||||
"reported title-case Ukrainian word corrects with all three dictionaries enabled");
|
||||
current = supplemental.suggest(L"Поточны ", 1, layouts, options);
|
||||
require(current && current->text == L"Поточні " && current->target == 2,
|
||||
"reported Ukrainian word corrects from Russian layout");
|
||||
require(!supplemental.suggest(L"Поточні ", 2, layouts, options),
|
||||
"correct Ukrainian word remains unchanged");
|
||||
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||
{
|
||||
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||
@@ -263,6 +335,8 @@ int main(int argc, char **argv)
|
||||
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
|
||||
"valid Russian release word stays unchanged");
|
||||
options.autoMaxRank = 50000;
|
||||
require(!supplemental.suggest(L"Gjnjxys ", 0, layouts, options),
|
||||
"supplemental current word respects explicit frequency limit");
|
||||
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
||||
"supplemental vocabulary respects explicit frequency limit");
|
||||
options.autoWords[L"uk"] = {L"релізик"};
|
||||
@@ -293,6 +367,22 @@ int main(int argc, char **argv)
|
||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
|
||||
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
|
||||
"cycle skips unchanged Russian to Ukrainian and wraps to English");
|
||||
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
|
||||
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
|
||||
"cycle skips unchanged Ukrainian to Russian");
|
||||
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
|
||||
"cycle keeps the next language when text changes");
|
||||
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
|
||||
"cycle preserves configured order for changed text");
|
||||
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
|
||||
"cycle does not use disabled English when all candidates are unchanged");
|
||||
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
|
||||
"unchanged digits finish after a bounded cycle");
|
||||
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
|
||||
"source outside cycle still skips unchanged candidates");
|
||||
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
||||
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
||||
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
||||
@@ -301,6 +391,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");
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,7 +4,7 @@ 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}}
|
||||
SUPPLEMENTAL={'uk': {'реліз': 50001, 'поточні': 50001}}
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
manifest={}
|
||||
for lang in ('en','uk','ru'):
|
||||
|
||||
Reference in New Issue
Block a user