Compare commits
13 Commits
release-v0.6.20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b51d341 | |||
| 0184b837f4 | |||
| ca553a5e7d | |||
| 55993c0f90 | |||
| 738c81dce2 | |||
| 22edece7cf | |||
| 908985d775 | |||
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 |
+13
-3
@@ -1,14 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.20 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.30 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,14 @@ 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)
|
||||
@@ -57,10 +65,12 @@ else()
|
||||
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 .)
|
||||
|
||||
@@ -16,6 +16,11 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||
Double-click the tray icon to open Settings, the bold default menu item.
|
||||
Menus support access keys: press the underlined letter while the menu is open (for example,
|
||||
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
||||
to activate a button, toggle a checkbox or focus a labeled field. Press Alt to reveal
|
||||
the underlines when Windows hides them. Shortcut-capture fields still record Alt combinations;
|
||||
press Tab to leave a shortcut field before using form access keys.
|
||||
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||
@@ -90,6 +95,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 +116,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,7 +167,37 @@ 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. 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.
|
||||
@@ -168,7 +207,7 @@ released repeatedly within one stroke. The **Line style** selector supports Soli
|
||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||
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**.
|
||||
@@ -220,6 +259,10 @@ 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.
|
||||
@@ -228,7 +271,11 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||
history and cloud synchronization. Paste is not blindly retried.
|
||||
history and cloud synchronization. Paste is not blindly retried: with manual paste readback
|
||||
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
|
||||
original and fresh native queries confirm that exactly the original range is still selected.
|
||||
Other controls and unknown/changed selections are not retried. A verified failed paste clears
|
||||
repeat-conversion state and does not switch the Windows layout.
|
||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||
@@ -278,21 +325,16 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
||||
|
||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||
|
||||
**Clear history… / Alt+H** in the history window clears all saved entries after confirmation,
|
||||
including pinned entries and entries hidden by search. No is the default choice. The current
|
||||
Windows clipboard and future recording are unchanged. Settings also retains its Clear history action.
|
||||
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||
in the context menu opens the original image in the built-in screenshot editor.
|
||||
Annotate it there, then copy the result. Opening the editor leaves the
|
||||
clipboard and original history entry unchanged. Text items still open as text;
|
||||
Enter and double-click still paste the selected item.
|
||||
|
||||
Local builds made with `build.ps1` show **[LOCAL DEV]** in window titles and
|
||||
the tray tooltip. The marker is enabled by `.local-dev` beside the executable;
|
||||
release packages do not include that marker.
|
||||
|
||||
Fast/inertial wheel events use a shared batching handler in history, layout and
|
||||
snippet lists, the log table, multiline text/details fields, combo boxes and
|
||||
scrollable settings windows. High-resolution partial wheel steps are retained.
|
||||
History pauses new preview loads while scrolling and keeps visible thumbnails
|
||||
cached, preventing continuous flicker and needless redraws.
|
||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||
|
||||
## Data and migration
|
||||
@@ -302,7 +344,14 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
|
||||
Diagnostic stages stay in a memory buffer of at most 128 events for 30 seconds.
|
||||
Selection diagnostics include the expected text, each copied candidate before and after suffix
|
||||
recovery, paced Shift+Left progress or batch delivery counts, and clipboard sequence changes,
|
||||
retry counts and elapsed times. Focus, caret geometry and keyboard layout are recorded when
|
||||
Windows exposes them; selection offsets are available for native EDIT controls and explicitly
|
||||
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
|
||||
or layout-refresh events during an operation. Failed selection verification includes the expected
|
||||
selection and actual copied text in the operation details, without implying that a paste occurred.
|
||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||
context. Successful operations stay in that bounded memory buffer and are included only when
|
||||
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
||||
@@ -354,6 +403,119 @@ 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.
|
||||
When synchronization is disabled, connection fields and sync actions are inactive. Check **Enable
|
||||
synchronization** to make them available. To disable an existing connection, uncheck it and click
|
||||
**Save**; this preserves the saved connection and password without starting a sync request.
|
||||
**Disconnect / forget password** remains available while synchronization is disabled.
|
||||
A blank password keeps the existing Windows credential for that server, login mount and username.
|
||||
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
||||
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
||||
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/`.
|
||||
|
||||
On the first connection, **Save / Sign in / Sync** merges the existing profiles without
|
||||
requiring a whole-profile replacement. Independent entries (such as snippets, exclusions and
|
||||
replacement pairs) are combined. Different values for the same setting remain on their respective
|
||||
sides and appear in **Resolve individual conflicts...**; no value is guessed without a common history.
|
||||
Nonconflicting changes synchronize immediately, and unresolved conflicts survive subsequent syncs.
|
||||
|
||||
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
||||
- **Download remote** replaces this PC's shared settings with the server profile.
|
||||
- If the server has no profile yet, choose **Upload local** to create it.
|
||||
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
|
||||
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
||||
|
||||
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. Full synchronization identifies
|
||||
missing keyboard layouts by language (when Windows can identify it) and exact ID.
|
||||
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
|
||||
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
|
||||
local layout cycle and its direct shortcuts unchanged while synchronizing the other shared settings.
|
||||
The server's layout cycle and direct shortcuts are preserved on automatic sync and explicit Upload;
|
||||
Download also keeps the local layout configuration. Later syncs cannot erase missing server layouts
|
||||
just because this PC does not have them installed. Uncheck the option to resume full layout sync.
|
||||
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
||||
|
||||
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||
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:
|
||||
@@ -373,13 +535,40 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
|
||||
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
||||
verified local build uses GCC/w64devkit.
|
||||
|
||||
## Automatic layout correction (experimental)
|
||||
## Automatic correction (experimental)
|
||||
|
||||
Enable **Automatically correct layout (experimental)** in Settings, then use **Auto-correction
|
||||
Enable **Automatic correction (experimental)** in Settings, then use **Auto-correction
|
||||
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
||||
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
||||
Target layouts must also be enabled in the layout cycle.
|
||||
|
||||
### Custom replacements and spelling typos
|
||||
|
||||
Two optional modes in **Auto-correction options…** extend layout correction. Both are off by
|
||||
default and require the main **Automatic correction (experimental)** checkbox to be enabled.
|
||||
|
||||
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||
are rejected. Each side must contain 1–32 letters of the same supported language, without
|
||||
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
|
||||
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
||||
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
||||
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||
and target must have at least four letters. Multiple candidates leave the input unchanged,
|
||||
even if a frequency limit would exclude one of them. Personal dictionary words participate.
|
||||
This is word-level spelling correction, not grammar or sentence-context correction.
|
||||
|
||||
Both modes run after Space, preserve surrounding punctuation and an initial capital, and keep
|
||||
the current layout. The selected dictionary languages, enabled layout cycle, minimum input
|
||||
length, excluded applications, never-correct list, protected fields and Undo behavior apply.
|
||||
ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped. A replacement
|
||||
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
||||
are included in settings export/import and synchronization. Undo restores the original word
|
||||
and suppresses its correction until restart.
|
||||
|
||||
### Layout correction and shared options
|
||||
|
||||
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
||||
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
||||
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
||||
@@ -394,6 +583,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.
|
||||
|
||||
Binary file not shown.
+9
-10
@@ -1,24 +1,23 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.20 — 2026-10-31
|
||||
## v0.6.30 — 2026-11-02
|
||||
|
||||
## Changes
|
||||
- Open clipboard-history images in the built-in screenshot editor with **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor** in the context menu. PNG, DIB and DIBV5 open at full resolution through an encrypted local cache, without requiring notifications or changing the clipboard/original history entry. Copy the annotated result explicitly from the editor.
|
||||
- Fix continuous history flicker by keeping visible previews cached, invalidating only completed preview rows and preserving the top entry across background refreshes.
|
||||
- Handle fast/inertial wheels through one shared batching mechanism for history, layout/snippet lists, the log table, multiline text/details fields, combo boxes and scrollable settings windows. Preserve partial wheel deltas, clamp list boundaries and pause new history previews until scrolling stops.
|
||||
- Mark local developer builds with **[LOCAL DEV]** in window titles and the tray tooltip. Release packages omit the development marker. This does not create a separate data profile.
|
||||
- Right-clicking a history entry selects that entry before showing its actions. Enter and double-click continue to paste; text items still open in the full-text viewer.
|
||||
- A manual paste confirmed unsuccessful by readback no longer records the expected replacement as a previous result or switches the Windows layout. This prevents repeated commands from searching for text that was never inserted.
|
||||
- A previous-result verification mismatch clears stale repeat-conversion state.
|
||||
- A writable native Windows EDIT field may receive one guarded paste retry: readback must equal the original text, and fresh native checks must confirm the exact original selection, unchanged focus and input context. Other controls, protected/read-only fields and unknown/changed selections are not retried.
|
||||
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
|
||||
|
||||
## Validation
|
||||
Passed 406 native regressions, 473 input/UI checks, shared wheel batching tests, screenshot editor UI, notification registration, 48 updater checks and updater progress checks. Manual testing confirmed image editing from history and resolved clipboard flicker and Logitech inertial-wheel beeping. Shared wheel tests cover lists, edits, tables, combo boxes and windows, partial steps, large bursts, selection preservation and list boundaries.
|
||||
The final isolated conversion integration run passed: permanently blocked paste, unexpected editor content, clearing fictional previous results, ignored-first-paste recovery, clipboard restoration, repeat conversion and partial-selection recovery. Typo integration also passed, including Undo and uninterrupted typing. 541 input/UI checks (240 DPI), 105 sync/protocol checks, 483 native regressions and 48 updater checks passed. All release executable versions and manifest hashes were verified. Earlier interactive attempts were interrupted by concurrent input or a transient clipboard-restoration assertion; the final isolated rerun passed.
|
||||
|
||||
## Limitations
|
||||
Image decoding or a normalized image larger than the 128 MiB editor-transfer limit can prevent opening; an error is shown. Existing clipboard, OCR and editor privilege limitations remain. The wheel over screenshot annotations changes size/thickness rather than scrolling.
|
||||
The cause of every ignored paste is not established. This release does not claim to solve all asynchronous selection failures in ChatGPT/browser editors. Keep Write log enabled and send complete operation details if the issue recurs. Native retry is bounded to one attempt; it is not a general retry policy for other applications. All existing dictionary-correction and synchronization limitations remain.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, development marker, Tesseract installation or language models.
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package includes all three executables, a release manifest, offline dictionaries, documentation and license notices. No personal settings, passwords, server address or clipboard history are included.
|
||||
|
||||
Build and package:
|
||||
|
||||
@@ -27,4 +26,4 @@ Build and package:
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
@@ -1 +1 @@
|
||||
0b3c4fd1346d0b4eeb73d276193c5d2789fd32986c3cc75384cf489f23629678 KSwitcher-win-x64.zip
|
||||
7e557851ab68a37a8211ea39de41e79314f460ef8ac6471be723d0cfe03cfba9 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -15,8 +15,8 @@ Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -
|
||||
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" }
|
||||
}
|
||||
@@ -29,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'
|
||||
@@ -41,15 +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/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/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/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' }
|
||||
@@ -57,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
|
||||
@@ -71,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,20,0
|
||||
PRODUCTVERSION 0,6,20,0
|
||||
FILEVERSION 0,6,30,0
|
||||
PRODUCTVERSION 0,6,30,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.20"
|
||||
VALUE "FileVersion", "0.6.30"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+189
-32
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "configuration.h"
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
#include "app_sync.inl"
|
||||
App *App::current = nullptr;
|
||||
namespace
|
||||
{
|
||||
@@ -71,6 +73,8 @@ class Transaction
|
||||
Clip snapshot;
|
||||
DWORD expected{};
|
||||
bool captured{};
|
||||
mutable bool invalidLogged{};
|
||||
unsigned copyNumber{};
|
||||
std::wstring adopted;
|
||||
|
||||
public:
|
||||
@@ -106,8 +110,18 @@ class Transaction
|
||||
}
|
||||
bool valid() const
|
||||
{
|
||||
return target && IsWindow(target) && GetForegroundWindow() == target &&
|
||||
app.inputVersion.load() == version;
|
||||
auto foreground = GetForegroundWindow();
|
||||
auto currentVersion = app.inputVersion.load();
|
||||
bool ok = target && IsWindow(target) && foreground == target && currentVersion == version;
|
||||
if (!ok && !invalidLogged)
|
||||
{
|
||||
invalidLogged = true;
|
||||
diagnosticEvent(L"Context invalid: target=" + std::to_wstring((uintptr_t)target) +
|
||||
L" alive=" + std::to_wstring(IsWindow(target)) + L" foreground=" +
|
||||
std::to_wstring((uintptr_t)foreground) + L" expectedVersion=" + std::to_wstring(version) +
|
||||
L" currentVersion=" + std::to_wstring(currentVersion));
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
void check()
|
||||
{
|
||||
@@ -142,10 +156,14 @@ class Transaction
|
||||
}
|
||||
std::wstring copy()
|
||||
{
|
||||
const auto started = GetTickCount64();
|
||||
const auto number = ++copyNumber;
|
||||
if (!valid())
|
||||
return {};
|
||||
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
||||
DWORD before = expected;
|
||||
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
|
||||
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target));
|
||||
if (!valid() || !chord(L'C', MOD_CONTROL))
|
||||
{
|
||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||
@@ -164,6 +182,10 @@ class Transaction
|
||||
DWORD now = GetClipboardSequenceNumber();
|
||||
if (now != changed)
|
||||
{
|
||||
diagnosticEvent(L"Copy sequence number=" + std::to_wstring(number) + L" from=" +
|
||||
std::to_wstring(changed) + L" to=" + std::to_wstring(now) + L" elapsedMs=" +
|
||||
std::to_wstring(GetTickCount64() - started) + L" owner=" +
|
||||
std::to_wstring((uintptr_t)GetClipboardOwner()));
|
||||
changedAgain |= changed != before;
|
||||
changed = now;
|
||||
stable = GetTickCount64();
|
||||
@@ -181,7 +203,8 @@ class Transaction
|
||||
return {};
|
||||
if (!chord(L'C', MOD_CONTROL))
|
||||
return {};
|
||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
|
||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
|
||||
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||
++attempts;
|
||||
retryAt = GetTickCount64() + 250;
|
||||
}
|
||||
@@ -189,6 +212,10 @@ class Transaction
|
||||
}
|
||||
if (changed == before || !valid())
|
||||
{
|
||||
diagnosticEvent(L"Copy end number=" + std::to_wstring(number) + L" attempts=" +
|
||||
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
|
||||
L" sequence=" + std::to_wstring(GetClipboardSequenceNumber()) + L" clipboardOpenBy=" +
|
||||
std::to_wstring((uintptr_t)GetOpenClipboardWindow()));
|
||||
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
||||
L"Copy failed: context changed");
|
||||
return {};
|
||||
@@ -199,6 +226,9 @@ class Transaction
|
||||
expected = changed;
|
||||
auto clip = readClipboard(app.window);
|
||||
auto text = clip.text();
|
||||
diagnosticEvent(L"Copy result number=" + std::to_wstring(number) + L" attempts=" +
|
||||
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
|
||||
L" text=" + diagnosticText(text));
|
||||
adopted = text;
|
||||
check();
|
||||
for (auto &f : clip.formats)
|
||||
@@ -227,9 +257,31 @@ 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.
|
||||
diagnosticEvent(L"Selection begin steps=" + std::to_wstring(count) + L" expected=" + diagnosticText(wordCandidate));
|
||||
GUITHREADINFO gui{sizeof(gui)};
|
||||
if (GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui))
|
||||
{
|
||||
wchar_t cls[128]{};
|
||||
GetClassNameW(gui.hwndFocus, cls, 128);
|
||||
std::wstring offsets = L"unavailable";
|
||||
if (_wcsicmp(cls, L"Edit") == 0)
|
||||
{
|
||||
DWORD_PTR range{};
|
||||
if (SendMessageTimeoutW(gui.hwndFocus, EM_GETSEL, 0, 0,
|
||||
SMTO_ABORTIFHUNG | SMTO_BLOCK, 25, &range) && (DWORD)range != 0xffffffff)
|
||||
offsets = std::to_wstring(LOWORD(range)) + L"," + std::to_wstring(HIWORD(range));
|
||||
}
|
||||
diagnosticEvent(L"Selection editor class=" + std::wstring(cls) + L" focus=" +
|
||||
std::to_wstring((uintptr_t)gui.hwndFocus) + L" caretWindow=" + std::to_wstring((uintptr_t)gui.hwndCaret) +
|
||||
L" caretRect=" + std::to_wstring(gui.rcCaret.left) + L"," + std::to_wstring(gui.rcCaret.top) +
|
||||
L"," + std::to_wstring(gui.rcCaret.right) + L"," + std::to_wstring(gui.rcCaret.bottom) +
|
||||
L" layout=" + std::to_wstring((uintptr_t)windowLayout(target)) + L" selectionOffsets=" + offsets);
|
||||
}
|
||||
else diagnosticEvent(L"Selection editor: focus/caret/selection unavailable");
|
||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
||||
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
||||
@@ -238,6 +290,7 @@ class Transaction
|
||||
return {};
|
||||
Sleep(25);
|
||||
auto text = copy();
|
||||
diagnosticEvent(L"Selection word-boundary match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||
if (valid() && matches(text))
|
||||
{
|
||||
diagnosticEvent(L"Selection: verified word boundary");
|
||||
@@ -247,7 +300,27 @@ class Transaction
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
return select(count) ? copy() : std::wstring{};
|
||||
auto text = select(count) ? copy() : std::wstring{};
|
||||
diagnosticEvent(L"Selection initial match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||
if (valid() && !matches(text) && !text.empty() &&
|
||||
(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 {};
|
||||
diagnosticEvent(L"Selection recovery Shift+Left sent=" + std::to_wstring(i + 1) + L" total=" + std::to_wstring(missing));
|
||||
Sleep(25);
|
||||
}
|
||||
text = copy();
|
||||
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
bool paste(const Clip &clip)
|
||||
{
|
||||
@@ -265,6 +338,32 @@ class Transaction
|
||||
Sleep(300);
|
||||
return true;
|
||||
}
|
||||
bool nativeSelectionIs(const std::wstring &original) const
|
||||
{
|
||||
if (!valid()) return false;
|
||||
GUITHREADINFO gui{sizeof(gui)};
|
||||
if (!GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui) || !gui.hwndFocus) return false;
|
||||
wchar_t cls[32]{};
|
||||
GetClassNameW(gui.hwndFocus, cls, 32);
|
||||
if (_wcsicmp(cls, L"Edit") != 0 ||
|
||||
(GetWindowLongPtrW(gui.hwndFocus, GWL_STYLE) & (ES_PASSWORD | ES_READONLY))) return false;
|
||||
auto query = [&](UINT message, WPARAM wp, LPARAM lp, DWORD_PTR &result)
|
||||
{
|
||||
return SendMessageTimeoutW(gui.hwndFocus, message, wp, lp,
|
||||
SMTO_ABORTIFHUNG | SMTO_BLOCK, 100, &result) != 0;
|
||||
};
|
||||
DWORD_PTR range{}, length{}, copied{};
|
||||
if (!query(EM_GETSEL, 0, 0, range) || (DWORD)range == 0xffffffff ||
|
||||
size_t(HIWORD(range) - LOWORD(range)) != original.size() ||
|
||||
!query(WM_GETTEXTLENGTH, 0, 0, length) || length > 65534 || HIWORD(range) > length) return false;
|
||||
std::wstring content(length + 1, L'\0');
|
||||
if (!query(WM_GETTEXT, content.size(), (LPARAM)content.data(), copied) || copied != length) return false;
|
||||
DWORD_PTR after{};
|
||||
GUITHREADINFO focused{sizeof(focused)};
|
||||
return query(EM_GETSEL, 0, 0, after) && after == range && valid() &&
|
||||
GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &focused) && focused.hwndFocus == gui.hwndFocus &&
|
||||
content.substr(LOWORD(range), original.size()) == original;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||
@@ -303,6 +402,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)
|
||||
@@ -457,8 +557,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);
|
||||
@@ -502,10 +610,12 @@ void App::applySettings(const Settings &s)
|
||||
run.reset();
|
||||
last.reset();
|
||||
++inputVersion;
|
||||
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
|
||||
}
|
||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||
scheduleHistoryOcr(s);
|
||||
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||
}
|
||||
@@ -529,6 +639,7 @@ void App::reloadLayouts()
|
||||
run.reset();
|
||||
last.reset();
|
||||
++inputVersion;
|
||||
if (busy) diagnosticEvent(L"Input context changed: layouts reloaded version=" + std::to_wstring(inputVersion.load()));
|
||||
}
|
||||
registerHotkeys(settings);
|
||||
}
|
||||
@@ -558,6 +669,7 @@ void App::resetInput()
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
++inputVersion;
|
||||
if (busy) diagnosticEvent(L"Input context changed: reset version=" + std::to_wstring(inputVersion.load()));
|
||||
run.reset();
|
||||
last.reset();
|
||||
}
|
||||
@@ -575,6 +687,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
if (isHotkey(vk, mods))
|
||||
return;
|
||||
++inputVersion;
|
||||
if (busy) diagnosticEvent(L"Input context changed: keyboard vk=" + std::to_wstring(vk) +
|
||||
L" modifiers=" + std::to_wstring(mods) + L" injected=" + std::to_wstring((k.flags & LLKHF_INJECTED) != 0) +
|
||||
L" version=" + std::to_wstring(inputVersion.load()));
|
||||
last.reset();
|
||||
HWND fg = GetForegroundWindow();
|
||||
HKL layout = windowLayout(fg);
|
||||
@@ -808,8 +923,11 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||
// range just like local clicks, including while a transaction is pending.
|
||||
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
||||
{
|
||||
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
|
||||
current->resetInput();
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
void App::dispatch(int command)
|
||||
@@ -979,9 +1097,10 @@ void App::transform(int command, int snippet)
|
||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
||||
{"expected", bounded(result)}, {"actual", bounded(actual)},
|
||||
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
|
||||
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
|
||||
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
|
||||
{"selectionExpected", bounded(expected)},
|
||||
{"originalUnits", text.size()}, {"expectedUnits", (verification == L"selection-mismatch" ? expected : result).size()}, {"actualUnits", actual.size()},
|
||||
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096 || expected.size() > 4096}};
|
||||
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
|
||||
}
|
||||
catch (...) {} // Logging must never abort a completed input transaction.
|
||||
@@ -1104,6 +1223,8 @@ void App::transform(int command, int snippet)
|
||||
if (newlines(text) != newlines(previous->result))
|
||||
{
|
||||
reason = L"undo-verification-mismatch";
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -1138,12 +1259,16 @@ 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));
|
||||
if (hint < 0)
|
||||
{
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
@@ -1155,12 +1280,20 @@ 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); });
|
||||
if (newlines(text) != newlines(previous->result))
|
||||
{
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
reason = L"previous-result-verification-mismatch";
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
last.reset();
|
||||
run.reset();
|
||||
}
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -1202,11 +1335,7 @@ 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);
|
||||
}
|
||||
@@ -1234,11 +1363,30 @@ void App::transform(int command, int snippet)
|
||||
if (tx.valid())
|
||||
{
|
||||
auto observed = probe.read((int)caretSteps(result));
|
||||
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||
// Retry once only with fresh native proof that the exact original range
|
||||
// is still selected. Never retry an unknown or changed editor state.
|
||||
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||
{
|
||||
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
||||
if (tx.paste(textClip(result)) && tx.valid())
|
||||
observed = probe.read((int)caretSteps(result));
|
||||
else
|
||||
observed.reset();
|
||||
}
|
||||
verification = probe.status;
|
||||
if (observed && tx.valid())
|
||||
{
|
||||
actual = *observed;
|
||||
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
||||
if (newlines(actual) != newlines(result))
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
last.reset();
|
||||
run.reset();
|
||||
diagnosticEvent(L"Paste rejected by readback: previous result cleared; layout not switched");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else reason = L"Paste not verified";
|
||||
}
|
||||
@@ -1423,28 +1571,28 @@ void App::recognizeEntry(std::wstring id)
|
||||
void App::trayMenu()
|
||||
{
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
|
||||
SetMenuDefaultItem(menu, 11, FALSE);
|
||||
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"Keyboard shortcuts…");
|
||||
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen awake");
|
||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
||||
HMENU pause = CreatePopupMenu();
|
||||
AppendMenuW(pause, MF_STRING, 20, L"Resume");
|
||||
AppendMenuW(pause, MF_STRING, 21, L"10 minutes");
|
||||
AppendMenuW(pause, MF_STRING, 22, L"1 hour");
|
||||
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
||||
AppendMenuW(menu, MF_STRING, 18, L"View log…");
|
||||
AppendMenuW(pause, MF_STRING, 20, L"&Resume");
|
||||
AppendMenuW(pause, MF_STRING, 21, L"&10 minutes");
|
||||
AppendMenuW(pause, MF_STRING, 22, L"1 &hour");
|
||||
AppendMenuW(pause, MF_STRING, 23, L"&Until restart");
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"&Pause clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 15, L"&Open data folder");
|
||||
AppendMenuW(menu, MF_STRING, 18, L"View &log…");
|
||||
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
||||
updateRunning ? L"Update (checking / preparing…)" :
|
||||
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
|
||||
updateRunning ? L"&Update (checking / preparing…)" :
|
||||
availableUpdate.empty() ? L"&Update" : L"&Update (new version available)");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
||||
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
||||
AppendMenuW(menu, MF_STRING, 16, L"&About KSwitcher");
|
||||
AppendMenuW(menu, MF_STRING, 17, L"E&xit");
|
||||
POINT pt;
|
||||
GetCursorPos(&pt);
|
||||
pasteTarget = GetForegroundWindow();
|
||||
@@ -1675,6 +1823,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.
|
||||
@@ -1777,6 +1930,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())
|
||||
@@ -1801,6 +1955,9 @@ int App::execute()
|
||||
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
|
||||
continue;
|
||||
}
|
||||
// Preserve history-specific Enter/Shift+Enter and Ctrl+digit handling.
|
||||
if (historyWindow && msg.message == WM_SYSCHAR && IsDialogMessageW(historyWindow, &msg))
|
||||
continue;
|
||||
if (settingsWindow && IsDialogMessageW(settingsWindow, &msg))
|
||||
continue;
|
||||
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
||||
|
||||
@@ -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 &){};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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, c.keepLocalLayouts); }, choices,
|
||||
c.keepLocalLayouts);
|
||||
if (result.success && c.keepLocalLayouts)
|
||||
result.status += L" Partial sync: this PC's layouts and their shortcuts were kept; server layouts were preserved.";
|
||||
}
|
||||
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
||||
catch (const std::exception &e) { result.status = wide(e.what()); }
|
||||
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, syncConnection.keepLocalLayouts);
|
||||
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; }
|
||||
}
|
||||
@@ -164,6 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||
if (!dictionaries.contains(sourceLanguage))
|
||||
return {};
|
||||
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||||
auto small = lower(word);
|
||||
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
|
||||
small.find_first_not_of(alphabet) == small.npos &&
|
||||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||
auto sameLayout = [&](std::wstring text)
|
||||
{
|
||||
if (word != small) text[0] = upper(text.substr(0, 1))[0];
|
||||
return AutoSuggestion{source, source, prefix + text + suffix};
|
||||
};
|
||||
if (settings.autoReplace && sameLanguage)
|
||||
{
|
||||
auto replacement = settings.autoReplacements.find(small);
|
||||
if (replacement != settings.autoReplacements.end() &&
|
||||
replacement->second.find_first_not_of(alphabet) == small.npos)
|
||||
return sameLayout(replacement->second);
|
||||
}
|
||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||
{
|
||||
auto found = settings.autoWords.find(language);
|
||||
@@ -203,7 +221,54 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
(!candidate || priority(i) < priority(candidate->target)))
|
||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||
}
|
||||
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||||
return candidate;
|
||||
// Generate only single edits. Stop at a second distinct dictionary hit,
|
||||
// including rare hits: frequency limits must never hide ambiguity.
|
||||
std::wstring found;
|
||||
int foundRank = 0;
|
||||
std::set<std::wstring> personalTargets;
|
||||
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
|
||||
for (const auto &text : it->second) personalTargets.insert(lower(text));
|
||||
auto consider = [&](const std::wstring &text)
|
||||
{
|
||||
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||||
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
|
||||
if (!rank) return true;
|
||||
if (!found.empty()) return false;
|
||||
found = text;
|
||||
foundRank = rank;
|
||||
return true;
|
||||
};
|
||||
for (size_t i = 0; i < small.size(); ++i)
|
||||
{
|
||||
auto text = small;
|
||||
text.erase(i, 1);
|
||||
if (!consider(text)) return {};
|
||||
if (i + 1 < small.size())
|
||||
{
|
||||
text = small;
|
||||
std::swap(text[i], text[i + 1]);
|
||||
if (!consider(text)) return {};
|
||||
}
|
||||
for (wchar_t c : alphabet)
|
||||
{
|
||||
text = small;
|
||||
text[i] = c;
|
||||
if (!consider(text)) return {};
|
||||
}
|
||||
}
|
||||
if (small.size() < 32)
|
||||
for (size_t i = 0; i <= small.size(); ++i)
|
||||
for (wchar_t c : alphabet)
|
||||
{
|
||||
auto text = small;
|
||||
text.insert(i, 1, c);
|
||||
if (!consider(text)) return {};
|
||||
}
|
||||
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
|
||||
return sameLayout(found);
|
||||
return {};
|
||||
}
|
||||
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const Layout &physical, const Settings &settings)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
@@ -459,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,12 +24,17 @@ 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;
|
||||
@@ -49,6 +54,8 @@ struct Settings
|
||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
bool autoReplace = false, autoTypos = false;
|
||||
std::map<std::wstring, std::wstring> autoReplacements;
|
||||
bool writeLog = true;
|
||||
bool screenshotAutoOpenEditor = false;
|
||||
bool useTesseract = false;
|
||||
@@ -71,6 +78,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
|
||||
@@ -92,5 +100,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
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &conten
|
||||
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
||||
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
||||
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
|
||||
field("original") + L"\r\n\r\nExpected selection:\r\n" + field("selectionExpected") +
|
||||
L"\r\n\r\nExpected result / verification target:\r\n" + field("expected") +
|
||||
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
||||
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
||||
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
||||
|
||||
+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 &);
|
||||
|
||||
+12
-4
@@ -396,22 +396,27 @@ 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();
|
||||
diagnosticEvent(L"SelectPrevious requested=" + std::to_wstring(count) +
|
||||
L" mode=" + (needsSelectionDelay(target) ? std::wstring(L"paced-25ms") : std::wstring(L"batch")));
|
||||
if (needsSelectionDelay(target))
|
||||
{
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
||||
return false;
|
||||
if (i < 32 || i + 1 == count)
|
||||
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
|
||||
Sleep(25);
|
||||
}
|
||||
return valid() && GetForegroundWindow() == target;
|
||||
@@ -433,7 +438,10 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
|
||||
}
|
||||
add(VK_SHIFT, KEYEVENTF_KEYUP);
|
||||
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
|
||||
auto sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
|
||||
diagnosticEvent(L"SelectPrevious batch offset=" + std::to_wstring(offset) + L" eventsRequested=" +
|
||||
std::to_wstring(inputs.size()) + L" eventsSent=" + std::to_wstring(sent));
|
||||
if (sent != inputs.size())
|
||||
return false;
|
||||
}
|
||||
return valid();
|
||||
@@ -573,7 +581,7 @@ DiagnosticBuffer diagnosticBuffer;
|
||||
}
|
||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
||||
{
|
||||
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
|
||||
while (!entries.empty() && (entries.size() >= 128 || now - entries.front().first > 30000))
|
||||
entries.pop_front();
|
||||
entries.emplace_back(now, message);
|
||||
if (!problem)
|
||||
|
||||
+350
-35
@@ -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;
|
||||
@@ -179,12 +182,18 @@ struct Capture
|
||||
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{};
|
||||
@@ -208,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);
|
||||
@@ -239,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];
|
||||
@@ -255,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): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
|
||||
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());
|
||||
@@ -297,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)
|
||||
{
|
||||
@@ -337,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;
|
||||
}
|
||||
@@ -366,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);
|
||||
@@ -380,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;
|
||||
@@ -484,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);
|
||||
@@ -599,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)
|
||||
{
|
||||
@@ -607,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);
|
||||
@@ -616,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)),
|
||||
@@ -650,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));
|
||||
@@ -673,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);
|
||||
@@ -684,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];
|
||||
@@ -699,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));
|
||||
@@ -755,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);
|
||||
@@ -771,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;
|
||||
@@ -785,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;
|
||||
@@ -821,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);
|
||||
@@ -846,9 +1024,12 @@ void edit(HWND w, Capture &c)
|
||||
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); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"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)
|
||||
@@ -938,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));
|
||||
@@ -1023,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;
|
||||
@@ -1046,12 +1230,15 @@ 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;
|
||||
@@ -1063,6 +1250,12 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
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];
|
||||
@@ -1079,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)
|
||||
@@ -1094,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);
|
||||
@@ -1118,7 +1316,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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);
|
||||
@@ -1128,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)
|
||||
@@ -1172,6 +1372,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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);
|
||||
}
|
||||
@@ -1179,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; }
|
||||
@@ -1200,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)};
|
||||
@@ -1210,6 +1439,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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;
|
||||
@@ -1303,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)
|
||||
@@ -1313,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);
|
||||
@@ -1325,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;
|
||||
@@ -1334,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;
|
||||
@@ -1384,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));
|
||||
}
|
||||
@@ -1402,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);
|
||||
}
|
||||
}
|
||||
@@ -1449,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};
|
||||
@@ -1528,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;
|
||||
@@ -1564,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) {
|
||||
@@ -1648,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);
|
||||
@@ -1658,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) {
|
||||
@@ -1713,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);
|
||||
@@ -1767,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);
|
||||
|
||||
+59
-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)
|
||||
@@ -92,6 +108,19 @@ void validateSettings(const Settings &s)
|
||||
throw std::runtime_error("Unsupported auto-correction language");
|
||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||
throw std::runtime_error("Too many auto-correction exceptions");
|
||||
if (s.autoReplacements.size() > 10000)
|
||||
throw std::runtime_error("Too many automatic replacements");
|
||||
for (const auto &[from, to] : s.autoReplacements)
|
||||
{
|
||||
bool valid = false;
|
||||
for (auto alphabet : {L"abcdefghijklmnopqrstuvwxyz", L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
|
||||
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||
from == lower(from) && to == lower(to) && from != to &&
|
||||
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
|
||||
if (!valid)
|
||||
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
|
||||
}
|
||||
for (const auto &[language, words] : s.autoWords)
|
||||
{
|
||||
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||
@@ -107,8 +136,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");
|
||||
@@ -150,6 +185,15 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||
s.awake = j.value("preventScreenLock", false);
|
||||
s.autoCorrect = j.value("autoCorrectLayout", false);
|
||||
s.autoReplace = j.value("autoCorrectReplace", false);
|
||||
s.autoTypos = j.value("autoCorrectTypos", false);
|
||||
if (j.contains("autoCorrectReplacements"))
|
||||
for (auto &[from, to] : j.at("autoCorrectReplacements").items())
|
||||
{
|
||||
auto key = lower(wide(from));
|
||||
if (!s.autoReplacements.emplace(key, lower(wide(to.get<std::string>()))).second)
|
||||
throw std::runtime_error("Duplicate automatic replacement");
|
||||
}
|
||||
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||
@@ -203,7 +247,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;
|
||||
}
|
||||
@@ -240,6 +290,11 @@ Bytes encodeSettings(const Settings &s)
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
j["autoCorrectLayout"] = s.autoCorrect;
|
||||
j["autoCorrectReplace"] = s.autoReplace;
|
||||
j["autoCorrectTypos"] = s.autoTypos;
|
||||
j["autoCorrectReplacements"] = Json::object();
|
||||
for (const auto &[from, to] : s.autoReplacements)
|
||||
j["autoCorrectReplacements"][utf8(from)] = utf8(to);
|
||||
j["autoCorrectMaxRank"] = s.autoMaxRank;
|
||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
||||
@@ -264,7 +319,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;
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
#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)}, {"keepLocalLayouts", c.keepLocalLayouts}};
|
||||
}
|
||||
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.keepLocalLayouts = j.value("keepLocalLayouts", false);
|
||||
c.provider = wide(j.value("provider", "openbao"));
|
||||
c.server = wide(j.value("server", ""));
|
||||
c.mount = wide(j.value("mount", "kswitcher"));
|
||||
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, bool keepLocalLayouts)
|
||||
{
|
||||
auto j = parse(shared), original = parse(encodeSettings(local));
|
||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||
auto allowed = parse(sharedSyncSettings(local));
|
||||
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||
for (auto it = j.begin(); it != j.end(); ++it)
|
||||
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
|
||||
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
|
||||
std::set<std::string> installed;
|
||||
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
||||
std::set<std::string> missing;
|
||||
for (const auto &id : j.at("layouts"))
|
||||
if (!installed.contains(id.get<std::string>())) missing.insert(id.get<std::string>());
|
||||
for (const auto &key : j.at("layoutHotkeys"))
|
||||
if (!installed.contains(key.at("layoutId").get<std::string>())) missing.insert(key.at("layoutId").get<std::string>());
|
||||
if (!keepLocalLayouts && !missing.empty())
|
||||
{
|
||||
std::wstring message = L"Missing keyboard layouts on this PC: ";
|
||||
for (const auto &id : missing)
|
||||
{
|
||||
auto code = id.starts_with("win:") ? id.substr(4) : id;
|
||||
wchar_t language[128]{};
|
||||
if (code.size() == 8 && code.find_first_not_of("0123456789abcdefABCDEF") == code.npos)
|
||||
GetLocaleInfoW(MAKELCID(LOWORD(std::stoul(code, nullptr, 16)), SORT_DEFAULT), LOCALE_SENGLISHLANGUAGENAME,
|
||||
language, 128);
|
||||
if (message.back() != L' ') message += L"; ";
|
||||
message += (language[0] ? std::wstring(language) : L"Unknown layout") + L" [" + wide(id) + L"]";
|
||||
}
|
||||
message += L". Install these exact layouts, or enable 'Keep this PC's layouts' below and sync other settings.";
|
||||
throw std::runtime_error(utf8(message));
|
||||
}
|
||||
if (keepLocalLayouts)
|
||||
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = original.at(key);
|
||||
original.update(j);
|
||||
return decodeSettings(bytes(original), layouts);
|
||||
}
|
||||
#include "sync_merge.inl"
|
||||
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
|
||||
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices, bool keepLocalLayouts)
|
||||
{
|
||||
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);
|
||||
auto mergeLocal = local, mergeBase = base;
|
||||
if (keepLocalLayouts)
|
||||
{
|
||||
// Never upload a locally filtered projection over another PC's layout setup.
|
||||
const auto remoteJson = parse(remote.settings);
|
||||
auto preserve = [&](const Bytes &profile)
|
||||
{
|
||||
auto j = parse(profile);
|
||||
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = remoteJson.at(key);
|
||||
return bytes(j);
|
||||
};
|
||||
mergeLocal = preserve(local);
|
||||
if (!base.empty()) mergeBase = preserve(base);
|
||||
}
|
||||
SyncMerge merged;
|
||||
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
|
||||
else
|
||||
{
|
||||
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal)));
|
||||
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)));
|
||||
auto connection = j.at("connection");
|
||||
if (!connection.contains("keepLocalLayouts")) connection["keepLocalLayouts"] = false;
|
||||
return connection == configJson(c) ? bytes(j.at("settings")) : Bytes{};
|
||||
}
|
||||
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
|
||||
{
|
||||
atomicWrite(root / L"sync-base.bin", protect(bytes({{"connection", configJson(c)}, {"settings", parse(base)}})));
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
#include "storage.h"
|
||||
#include <memory>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
struct SyncConfig
|
||||
{
|
||||
bool enabled{};
|
||||
bool keepLocalLayouts{};
|
||||
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
||||
path = L"personal/settings", authMount = L"userpass", username;
|
||||
bool operator==(const SyncConfig &) const = default;
|
||||
};
|
||||
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> &, bool keepLocalLayouts = false);
|
||||
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 = {},
|
||||
bool keepLocalLayouts = false);
|
||||
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
|
||||
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||
for (auto &sn : j.at("snippets"))
|
||||
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
||||
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)
|
||||
{
|
||||
auto &replacements = j["autoCorrectReplacements"];
|
||||
for (auto it = replacements.begin(); it != replacements.end();)
|
||||
if (it.value().is_null()) it = replacements.erase(it);
|
||||
else ++it;
|
||||
Json snippets = Json::array(), deleted = Json::array();
|
||||
for (const auto &[id, item] : j.at("snippets").items())
|
||||
{
|
||||
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"},
|
||||
{"autoCorrectReplace", L"Custom automatic replacements"}, {"autoCorrectTypos", L"Dictionary typo correction"},
|
||||
{"autoCorrectReplacements", L"Replacement pairs"},
|
||||
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
|
||||
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
||||
{"trigger", L"Trigger"}, {"text", L"Text"}};
|
||||
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));
|
||||
// With no common history, union independent entries; differing scalar values
|
||||
// remain on their respective devices until the user resolves the conflict.
|
||||
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
|
||||
SyncMerge result;
|
||||
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
||||
std::set<std::string> collisions;
|
||||
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;
|
||||
}
|
||||
+108
-62
@@ -83,7 +83,7 @@ void arrangeWindow(HWND w)
|
||||
{
|
||||
int id = GetDlgCtrlID(child);
|
||||
RECT next{scale(w, r.left) - layout->x,
|
||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
||||
scale(w, r.top) - layout->y + ((id >= 110 && id <= 114) || id == 116 ? extraHeight : 0)};
|
||||
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
||||
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
||||
if (child == layout->stretch)
|
||||
@@ -493,7 +493,7 @@ bool selectedImage(HWND w, HistoryView *v)
|
||||
}
|
||||
void updateHistoryActions(HWND w, HistoryView *v)
|
||||
{
|
||||
SetDlgItemTextW(w,114,selectedImage(w,v)?L"Edit image":L"Open item");
|
||||
SetDlgItemTextW(w,114,selectedImage(w,v)?L"&Edit image":L"&Open item");
|
||||
}
|
||||
std::wstring selected(HWND w, HistoryView *v)
|
||||
{
|
||||
@@ -730,6 +730,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||
id = 110;
|
||||
if (id == 116)
|
||||
{
|
||||
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items? This also deletes items hidden by the current search.",
|
||||
L"Clear history", MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2) == IDYES)
|
||||
v->app->clearHistory();
|
||||
return 0;
|
||||
}
|
||||
auto entry = selected(w, v);
|
||||
if (id == 110 || id == 111)
|
||||
{
|
||||
@@ -854,12 +861,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
p={row.left,row.top}; ClientToScreen(list,&p);
|
||||
}
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"Edit image in screenshot editor":L"Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
|
||||
AppendMenuW(menu, MF_STRING, 110, L"&Paste");
|
||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain &text");
|
||||
AppendMenuW(menu, MF_STRING, 112, L"P&in / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"&Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"&Edit image in screenshot editor":L"&Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"&Copy text from image (OCR)");
|
||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
||||
DestroyMenu(menu);
|
||||
if (cmd)
|
||||
@@ -1112,6 +1119,25 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||
draft.autoPrefixChars = value(w, 408);
|
||||
draft.autoSuffixChars = value(w, 409);
|
||||
draft.autoReplace = checked(w, 414);
|
||||
draft.autoTypos = checked(w, 415);
|
||||
draft.autoReplacements.clear();
|
||||
std::wistringstream replacements(lower(value(w, 416)));
|
||||
std::wstring line;
|
||||
auto trim = [](std::wstring text)
|
||||
{
|
||||
auto first = text.find_first_not_of(L" \t\r\n");
|
||||
return first == text.npos ? std::wstring{} : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
|
||||
};
|
||||
while (std::getline(replacements, line))
|
||||
{
|
||||
if (trim(line).empty()) continue;
|
||||
auto equals = line.find(L'=');
|
||||
if (equals == line.npos)
|
||||
throw std::runtime_error("Enter one replacement per line: typo=correction");
|
||||
if (!draft.autoReplacements.emplace(trim(line.substr(0, equals)), trim(line.substr(equals + 1))).second)
|
||||
throw std::runtime_error("Each replacement typo must occur only once");
|
||||
}
|
||||
if (draft.autoLanguages.size() < 2)
|
||||
throw std::runtime_error("Select at least two languages");
|
||||
validateSettings(draft);
|
||||
@@ -1123,6 +1149,9 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
view->settings->draft.autoMinLength = draft.autoMinLength;
|
||||
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
|
||||
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
|
||||
view->settings->draft.autoReplace = draft.autoReplace;
|
||||
view->settings->draft.autoTypos = draft.autoTypos;
|
||||
view->settings->draft.autoReplacements = std::move(draft.autoReplacements);
|
||||
DestroyWindow(w);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -1153,15 +1182,15 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
void showAutoOptions(HWND parent, SettingsView *v)
|
||||
{
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||
560, 680);
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic correction", autoOptionsProc,
|
||||
560, 880);
|
||||
windowLayoutState(w)->autoOptions = true;
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||
checkbox(w, L"English", 401, 18, 48, 150);
|
||||
checkbox(w, L"Ukrainian", 402, 188, 48, 150);
|
||||
checkbox(w, L"Russian", 403, 358, 48, 150);
|
||||
checkbox(w, L"&English", 401, 18, 48, 150);
|
||||
checkbox(w, L"&Ukrainian", 402, 188, 48, 150);
|
||||
checkbox(w, L"&Russian", 403, 358, 48, 150);
|
||||
for (auto &lang : v->draft.autoLanguages)
|
||||
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
||||
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
||||
@@ -1174,7 +1203,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
||||
{
|
||||
int x = 18 + (id - 411) * 178;
|
||||
label(w, id == 411 ? L"English" : id == 412 ? L"Ukrainian" : L"Russian", x, 302, 168);
|
||||
label(w, id == 411 ? L"En&glish" : id == 412 ? L"U&krainian" : L"Ru&ssian", x, 302, 168);
|
||||
edit(w, id, x, 328, 168, 56, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||
auto words = v->draft.autoWords.find(language);
|
||||
if (words != v->draft.autoWords.end()) value(w, id, commaList(words->second));
|
||||
@@ -1192,10 +1221,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
value(w, 408, v->draft.autoPrefixChars);
|
||||
value(w, 409, v->draft.autoSuffixChars);
|
||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
||||
button(w, L"Open dictionary folder", 407, 18, 562, 220);
|
||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
||||
button(w, L"OK", IDOK, 326, 632, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
||||
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
||||
check(w, 414, v->draft.autoReplace);
|
||||
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
|
||||
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||
std::wstring pairs;
|
||||
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
|
||||
value(w, 416, pairs);
|
||||
checkbox(w, L"Correct spelling &typos using dictionaries (experimental)", 415, 18, 700, 524);
|
||||
check(w, 415, v->draft.autoTypos);
|
||||
label(w, L"Single edits, 4+ letters; ambiguous words are left unchanged.", 18, 730, 525);
|
||||
button(w, L"Open dictionary &folder", 407, 18, 762, 220);
|
||||
label(w, L"Click OK, then Save in Settings. Enable automatic correction there.", 18, 800, 525);
|
||||
button(w, L"&OK", IDOK, 326, 832, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 440, 832, 100);
|
||||
RECT initial{};
|
||||
GetWindowRect(w, &initial);
|
||||
initial.right += scale(w, 200);
|
||||
@@ -1278,26 +1317,27 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
|
||||
label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600);
|
||||
checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
|
||||
checkbox(w, L"&Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
|
||||
check(w, 501, v->draft.useTesseract);
|
||||
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
|
||||
edit(w, 502, 18, 118, 484);
|
||||
value(w, 502, v->draft.tesseractPath);
|
||||
button(w, L"Browse…", 504, 514, 117, 106);
|
||||
button(w, L"&Browse…", 504, 514, 117, 106);
|
||||
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
|
||||
edit(w, 503, 18, 188, 602);
|
||||
value(w, 503, v->draft.tesseractLanguages);
|
||||
button(w, L"Check installation", 505, 18, 228, 180);
|
||||
button(w, L"Check &installation", 505, 18, 228, 180);
|
||||
control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24);
|
||||
button(w, L"Download Tesseract…", 507, 18, 266, 180);
|
||||
button(w, L"&Download Tesseract…", 507, 18, 266, 180);
|
||||
label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408);
|
||||
label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602);
|
||||
label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602);
|
||||
button(w, L"OK", IDOK, 406, 390, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
|
||||
button(w, L"&OK", IDOK, 406, 390, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 520, 390, 100);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
#include "ui_sync.inl"
|
||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED)
|
||||
@@ -1334,6 +1374,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);
|
||||
@@ -1455,7 +1496,10 @@ 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)
|
||||
@@ -1492,7 +1536,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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);
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
@@ -1601,18 +1645,19 @@ void showHistory(App &app, bool visible)
|
||||
app.historyWindow = w;
|
||||
windowLayoutState(w)->history = true;
|
||||
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
||||
label(w, L"Search clipboard history", 16, 12, 500);
|
||||
label(w, L"&Search clipboard history", 16, 12, 500);
|
||||
edit(w, 101, 16, 38, 728);
|
||||
auto list = control(w, L"LISTBOX", L"",
|
||||
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
||||
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
||||
102, 16, 76, 728, 360);
|
||||
SendMessageW(list, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||
button(w, L"Paste", 110, 16, 452, 90);
|
||||
button(w, L"Plain text", 111, 114, 452, 100);
|
||||
button(w, L"Pin / unpin", 112, 222, 452, 110);
|
||||
button(w, L"Delete", 113, 340, 452, 90);
|
||||
button(w, L"Open item", 114, 438, 452, 100);
|
||||
button(w, L"&Paste", 110, 16, 452, 90);
|
||||
button(w, L"Plain &text", 111, 114, 452, 100);
|
||||
button(w, L"P&in / unpin", 112, 222, 452, 110);
|
||||
button(w, L"&Delete", 113, 340, 452, 90);
|
||||
button(w, L"&Open item", 114, 438, 452, 100);
|
||||
button(w, L"Clear &history…", 116, 546, 452, 150);
|
||||
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
||||
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
||||
}
|
||||
@@ -1651,13 +1696,13 @@ void showSettings(App &app, bool visible)
|
||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660, 0, false);
|
||||
app.settingsWindow = w;
|
||||
label(w, L"Text conversion", 18, 16);
|
||||
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 390);
|
||||
checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 390);
|
||||
checkbox(w, L"Record clipboard history", 203, 18, 106);
|
||||
checkbox(w, L"Start with Windows", 204, 18, 133);
|
||||
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
|
||||
checkbox(w, L"Keep screen awake", 205, 18, 184, 210);
|
||||
checkbox(w, L"Write log", 208, 240, 184, 160);
|
||||
checkbox(w, L"Con&vert recently typed text when nothing is selected", 201, 18, 43, 390);
|
||||
checkbox(w, L"Switch Windows input &layout after conversion", 202, 18, 70, 390);
|
||||
checkbox(w, L"Record clip&board history", 203, 18, 106);
|
||||
checkbox(w, L"Start with &Windows", 204, 18, 133);
|
||||
checkbox(w, L"Start with Windows as ad&ministrator", 206, 18, 160, 395);
|
||||
checkbox(w, L"&Keep screen awake", 205, 18, 184, 210);
|
||||
checkbox(w, L"Write lo&g", 208, 240, 184, 160);
|
||||
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
|
||||
L"Clipboard history", L"Screen capture"};
|
||||
@@ -1666,18 +1711,18 @@ void showSettings(App &app, bool visible)
|
||||
label(w, names[i], 420, 48 + i * 30, 115);
|
||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||
}
|
||||
checkbox(w, L"Open editor after screen capture", 209, 420, 224, 340);
|
||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
||||
checkbox(w, L"Open editor after screen capt&ure", 209, 420, 224, 340);
|
||||
checkbox(w, L"Automatic correction (e&xperimental)", 207, 18, 210, 395);
|
||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||
18, 262, 470, 172);
|
||||
button(w, L"Move up", 232, 504, 262, 120);
|
||||
button(w, L"Move down", 233, 632, 262, 128);
|
||||
checkbox(w, L"Include in cycle", 230, 504, 300, 240);
|
||||
button(w, L"Move u&p", 232, 504, 262, 120);
|
||||
button(w, L"Move &down", 233, 632, 262, 128);
|
||||
checkbox(w, L"&Include in cycle", 230, 504, 300, 240);
|
||||
label(w, L"Direct layout shortcut", 504, 332, 240);
|
||||
hotkeyEdit(app, w, 231, 504, 356, 256);
|
||||
label(w, L"Backspace / Delete clears a shortcut", 504, 382, 256);
|
||||
button(w, L"Refresh layouts", 253, 504, 408, 150);
|
||||
button(w, L"Re&fresh layouts", 253, 504, 408, 150);
|
||||
label(w, L"History limits", 18, 444);
|
||||
label(w, L"Items", 18, 478, 50);
|
||||
edit(w, 240, 70, 474, 90);
|
||||
@@ -1685,17 +1730,18 @@ void showSettings(App &app, bool visible)
|
||||
edit(w, 241, 260, 474, 90);
|
||||
label(w, L"Per item MiB", 370, 478, 100);
|
||||
edit(w, 242, 475, 474, 80);
|
||||
button(w, L"Clear history…", 254, 580, 474, 180);
|
||||
button(w, L"Clear &history…", 254, 580, 474, 180);
|
||||
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
||||
edit(w, 243, 18, 544, 742);
|
||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
||||
button(w, L"View log…", 256, 278, 575, 220);
|
||||
button(w, L"OCR options…", 257, 518, 575, 242);
|
||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||
button(w, L"Save", IDOK, 548, 606, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||
button(w, L"&Auto-correction options…", 255, 18, 575, 240);
|
||||
button(w, L"View l&og…", 256, 278, 575, 220);
|
||||
button(w, L"OCR op&tions…", 257, 518, 575, 242);
|
||||
button(w, L"Impo&rt settings…", 250, 18, 606, 145);
|
||||
button(w, L"&Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"S&nippets…", 252, 328, 606, 110);
|
||||
button(w, L"S&ync...", 258, 448, 606, 90);
|
||||
button(w, L"&Save", IDOK, 548, 606, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 660, 606, 100);
|
||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (visible)
|
||||
{
|
||||
@@ -1715,17 +1761,17 @@ void showSnippets(App &app)
|
||||
label(w, L"Triggers", 16, 16);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
||||
16, 44, 200, 380);
|
||||
button(w, L"Add", 310, 16, 438, 90);
|
||||
button(w, L"Delete", 311, 116, 438, 100);
|
||||
label(w, L"Trigger (for example ;addr)", 236, 16, 470);
|
||||
button(w, L"&Add", 310, 16, 438, 90);
|
||||
button(w, L"&Delete", 311, 116, 438, 100);
|
||||
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
||||
edit(w, 302, 236, 44, 488);
|
||||
label(w, L"Replacement text", 236, 82, 480);
|
||||
label(w, L"&Replacement text", 236, 82, 480);
|
||||
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
||||
button(w, L"Insert Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"Save", IDOK, 510, 478, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||
button(w, L"Insert cursor &marker", 312, 236, 430, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"&Save", IDOK, 510, 478, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 624, 478, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
|
||||
+9
-9
@@ -309,18 +309,18 @@ void showLogs(App &app)
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||
button(w, L"Refresh", LogRefresh, 310, 14, 100);
|
||||
button(w, L"Copy selected", LogCopy, 424, 14, 140);
|
||||
button(w, L"Clear log", LogClear, 578, 14, 100);
|
||||
button(w, L"&Refresh", LogRefresh, 310, 14, 100);
|
||||
button(w, L"&Copy selected", LogCopy, 424, 14, 140);
|
||||
button(w, L"Clear &log", LogClear, 578, 14, 100);
|
||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
|
||||
label(w, L"Program", 16, 78, 170);
|
||||
label(w, L"&Program", 16, 78, 170);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
|
||||
label(w, L"Command", 206, 78, 200);
|
||||
label(w, L"Co&mmand", 206, 78, 200);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
|
||||
label(w, L"Result", 426, 78, 300);
|
||||
label(w, L"Res&ult", 426, 78, 300);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
|
||||
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
|
||||
button(w, L"Reset filters", LogReset, 938, 98, 146);
|
||||
checkbox(w, L"&Only problems", LogProblems, 752, 102, 166);
|
||||
button(w, L"Reset &filters", LogReset, 938, 98, 146);
|
||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&common);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
@@ -344,7 +344,7 @@ void showLogs(App &app)
|
||||
w, L"STATIC",
|
||||
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
|
||||
16, 712, 860, 22);
|
||||
button(w, L"Close", IDCANCEL, 974, 708, 110);
|
||||
button(w, L"Clos&e", IDCANCEL, 974, 708, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->details = details;
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
#include "ui_sync_conflicts.inl"
|
||||
struct SyncView { HWND parent; App *app; };
|
||||
void updateSyncControls(HWND w, App &app)
|
||||
{
|
||||
bool enabled = checked(w, 601);
|
||||
bool savedEnabled = loadSyncConfig(app.data).enabled;
|
||||
for (int id : {602, 603, 604, 605, 606, 607, 610, 611, 613})
|
||||
EnableWindow(GetDlgItem(w, id), enabled);
|
||||
EnableWindow(GetDlgItem(w, 612), enabled && savedEnabled);
|
||||
EnableWindow(GetDlgItem(w, IDOK), enabled || savedEnabled);
|
||||
value(w, IDOK, enabled ? L"&Save / Sign in / Sync" : L"&Save");
|
||||
std::wstring status;
|
||||
if (!enabled)
|
||||
status = savedEnabled ? L"Click Save to disable synchronization." :
|
||||
L"Synchronization is disabled. Enable it above to configure and sync.";
|
||||
else if (!savedEnabled)
|
||||
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
|
||||
else
|
||||
status = app.syncCommand();
|
||||
if (value(w, 608) != status) value(w, 608, status);
|
||||
}
|
||||
SyncConfig syncFields(HWND w)
|
||||
{
|
||||
SyncConfig c;
|
||||
c.enabled = checked(w, 601);
|
||||
c.keepLocalLayouts = checked(w, 613);
|
||||
c.server = value(w, 602);
|
||||
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
||||
c.username = lower(value(w, 603)); c.mount = value(w, 605);
|
||||
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)
|
||||
{
|
||||
updateSyncControls(w, *v->app);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 601 && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
updateSyncControls(w, *v->app);
|
||||
return 0;
|
||||
}
|
||||
if ((id == IDOK || id == 610 || id == 611 || id == 612) &&
|
||||
!IsWindowEnabled(GetDlgItem(w, id))) return 0;
|
||||
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
|
||||
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||
if (id == 609)
|
||||
{
|
||||
auto c = loadSyncConfig(v->app->data);
|
||||
c.enabled = false;
|
||||
saveSyncConfig(v->app->data, c);
|
||||
forgetSyncPassword(c);
|
||||
check(w, 601, false);
|
||||
value(w, 604, L"");
|
||||
updateSyncControls(w, *v->app);
|
||||
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
||||
KillTimer(w, 1);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK || id == 610 || id == 611)
|
||||
{
|
||||
if (!checked(w, 601))
|
||||
{
|
||||
if (id != IDOK) return 0;
|
||||
auto c = loadSyncConfig(v->app->data);
|
||||
c.enabled = false;
|
||||
saveSyncConfig(v->app->data, c);
|
||||
value(w, 604, L"");
|
||||
updateSyncControls(w, *v->app);
|
||||
return 0;
|
||||
}
|
||||
if (id != IDOK && MessageBoxW(w, id == 610 ?
|
||||
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
|
||||
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
|
||||
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
||||
auto c = syncFields(w);
|
||||
if (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));
|
||||
updateSyncControls(w, *v->app);
|
||||
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, 672);
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
|
||||
checkbox(w, L"&Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
|
||||
check(w, 601, c.enabled);
|
||||
label(w, L"Se&rver (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
|
||||
label(w, L"User&name", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
|
||||
label(w, L"&Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
|
||||
label(w, L"Leave password blank to use the saved Windows credential.", 190, 166, 450);
|
||||
label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
|
||||
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
|
||||
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
|
||||
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
|
||||
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
value(w, 608, app.syncCommand());
|
||||
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
|
||||
check(w, 613, c.keepLocalLayouts);
|
||||
button(w, L"&Save / Sign in / Sync", IDOK, 18, 484, 200);
|
||||
button(w, L"&Download remote", 610, 230, 484, 195);
|
||||
button(w, L"&Upload local", 611, 437, 484, 203);
|
||||
button(w, L"Resolve &individual conflicts...", 612, 18, 530, 300);
|
||||
label(w, L"Background check: every 10 minutes", 330, 534, 310);
|
||||
button(w, L"Disconnect / &forget password", 609, 18, 582, 290);
|
||||
button(w, L"&Close", IDCANCEL, 540, 582, 100);
|
||||
updateSyncControls(w, app);
|
||||
EnableWindow(parent, FALSE);
|
||||
SetTimer(w, 1, 1000, nullptr);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
@@ -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.20";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.30";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+145
-4
@@ -15,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) {
|
||||
@@ -568,6 +599,9 @@ int main(int argc, char **argv) {
|
||||
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
||||
app.resetInput();
|
||||
app.handleKeyboard(event('A',0x1e),true,0);
|
||||
if (app.run.keys!=L"a" && app.run.keys!=L"A")
|
||||
std::cerr << "Input fixture foreground=" << (uintptr_t)GetForegroundWindow()
|
||||
<< " recorded=" << ks::utf8(app.run.keys) << '\n';
|
||||
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
|
||||
auto keys=app.run.keys; auto version=app.inputVersion.load();
|
||||
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
||||
@@ -595,6 +629,94 @@ 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");
|
||||
// Mnemonics only target visible controls; show without taking foreground focus.
|
||||
ShowWindow(app.settingsWindow,SW_SHOWNOACTIVATE);
|
||||
auto previousActive=SetActiveWindow(app.settingsWindow);
|
||||
auto awakeBefore=SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0);
|
||||
MSG accessKey{}; accessKey.hwnd=GetDlgItem(app.settingsWindow,201);
|
||||
accessKey.message=WM_SYSCHAR; accessKey.wParam=L'k'; accessKey.lParam=1LL<<29;
|
||||
require(IsDialogMessageW(app.settingsWindow,&accessKey),"Alt+K is handled as a dialog access key");
|
||||
MSG dispatched{};
|
||||
while(PeekMessageW(&dispatched,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&dispatched); DispatchMessageW(&dispatched); }
|
||||
require(SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0)!=awakeBefore,
|
||||
"Alt+K toggles Keep screen awake");
|
||||
IsDialogMessageW(app.settingsWindow,&accessKey);
|
||||
while(PeekMessageW(&dispatched,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&dispatched); DispatchMessageW(&dispatched); }
|
||||
require(SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0)==awakeBefore,
|
||||
"repeated access key restores checkbox state");
|
||||
ShowWindow(app.settingsWindow,SW_HIDE);
|
||||
SetActiveWindow(previousActive);
|
||||
|
||||
DestroyWindow(app.settingsWindow);
|
||||
ks::saveSyncConfig(app.data,ks::SyncConfig{});
|
||||
ks::showSettings(app,false);
|
||||
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");
|
||||
for(int id:{602,603,604,605,606,607,610,611,612,613,IDOK})
|
||||
require(!IsWindowEnabled(GetDlgItem(syncWindow,id)),"disabled synchronization disables dependent controls");
|
||||
require(IsWindowEnabled(GetDlgItem(syncWindow,601)) && IsWindowEnabled(GetDlgItem(syncWindow,609)) &&
|
||||
IsWindowEnabled(GetDlgItem(syncWindow,IDCANCEL)),"enable, forget password and close remain available");
|
||||
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||
require(!FindWindowW(L"KSwitcher.SyncConflicts",nullptr),"disabled conflict action is ignored");
|
||||
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_CHECKED,0);
|
||||
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||
for(int id:{602,603,604,605,606,607,610,611,613,IDOK})
|
||||
require(IsWindowEnabled(GetDlgItem(syncWindow,id)),"enabling synchronization immediately enables connection controls");
|
||||
require(!ks::loadSyncConfig(app.data).enabled,"checking enable alone does not connect or persist");
|
||||
auto enabledSync=ks::loadSyncConfig(app.data);
|
||||
enabledSync.enabled=true;
|
||||
enabledSync.server=L"https://sync.example.test";
|
||||
enabledSync.username=L"test";
|
||||
ks::saveSyncConfig(app.data,enabledSync);
|
||||
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||
ks::InputRegressionAccess::sampleConflicts(app);
|
||||
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
||||
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,613,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");
|
||||
}
|
||||
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_UNCHECKED,0);
|
||||
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||
require(IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save remains available to persist disabling sync");
|
||||
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
|
||||
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
|
||||
require(!IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save disables after disabled state is persisted");
|
||||
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
|
||||
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||
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");
|
||||
@@ -606,21 +728,31 @@ int main(int argc, char **argv) {
|
||||
GetWindowRect(autoOptions,&original);
|
||||
// Use a client size above the scrolling baseline at every DPI.
|
||||
UINT dpi=GetDpiForWindow(autoOptions);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(1100,dpi,96),
|
||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||
RECT clientBefore{}; GetClientRect(autoOptions,&clientBefore);
|
||||
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT clientAfter{}; GetClientRect(autoOptions,&clientAfter);
|
||||
// Windows can constrain oversized windows to the current desktop.
|
||||
// Check layout against the actual available client space.
|
||||
int extraBeforeX=std::max(0,int(clientBefore.right)-MulDiv(560,dpi,96));
|
||||
int extraAfterX=std::max(0,int(clientAfter.right)-MulDiv(560,dpi,96));
|
||||
int extraY=std::max(0,int(clientAfter.bottom)-MulDiv(880,dpi,96))-
|
||||
std::max(0,int(clientBefore.bottom)-MulDiv(880,dpi,96));
|
||||
for(int i=0;i<3;++i) {
|
||||
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
||||
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
||||
int extraX=(extraAfterX*(i+1)/3-extraAfterX*i/3)-
|
||||
(extraBeforeX*(i+1)/3-extraBeforeX*i/3);
|
||||
require(after[i].right-after[i].left==before[i].right-before[i].left+extraX &&
|
||||
after[i].bottom-after[i].top==before[i].bottom-before[i].top+extraY,
|
||||
"dictionary columns share extra width and receive extra height");
|
||||
}
|
||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
||||
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
|
||||
require(buttonAfter.left-buttonBefore.left==extraAfterX-extraBeforeX && buttonAfter.top-buttonBefore.top==extraY,
|
||||
"auto-correction buttons follow bottom-right corner");
|
||||
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||
after[2].bottom<buttonAfter.top,
|
||||
@@ -637,6 +769,9 @@ int main(int argc, char **argv) {
|
||||
"personal dictionary field fits options window");
|
||||
}
|
||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
||||
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
|
||||
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
||||
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||
@@ -646,6 +781,12 @@ int main(int argc, char **argv) {
|
||||
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
||||
require(std::wstring(personalWords)==L"релізик, словечко",
|
||||
"personal dictionary normalizes case, newlines and duplicate words");
|
||||
wchar_t pairs[128]{};
|
||||
GetDlgItemTextW(autoOptions,416,pairs,128);
|
||||
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
|
||||
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
||||
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
||||
"typo options and normalized replacement pairs survive reopening");
|
||||
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||
ks::showHistory(app,false);
|
||||
|
||||
+130
-1
@@ -9,6 +9,7 @@ namespace ks
|
||||
{
|
||||
struct InputRegressionAccess
|
||||
{
|
||||
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||
static bool deferring(App &app)
|
||||
{
|
||||
std::lock_guard lock(app.stateMutex);
|
||||
@@ -23,13 +24,21 @@ HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
bool captured = false, failed = false;
|
||||
bool conversionOnly = false;
|
||||
bool typosOnly = false;
|
||||
int pasteBehavior = 0;
|
||||
int enterPresses = 0;
|
||||
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||
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
|
||||
@@ -72,6 +81,7 @@ LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_P
|
||||
}
|
||||
if (message == WM_PASTE && pasteBehavior)
|
||||
{
|
||||
if (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
|
||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||
return 0;
|
||||
}
|
||||
@@ -205,10 +215,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);
|
||||
@@ -361,6 +373,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||||
"blocked paste records original text remaining in the actual editor");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||||
require(!ks::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
|
||||
pasteBehavior = 2;
|
||||
prepare(L"ghbdtn", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
@@ -371,6 +384,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||
"unexpected editor content records readback mismatch");
|
||||
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
|
||||
pasteBehavior = 0;
|
||||
prepare(L"привет", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
@@ -392,8 +406,67 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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");
|
||||
app->settings.writeLog = true;
|
||||
prepare(L"prefix wrong ", false);
|
||||
record(L"other ");
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 48:
|
||||
{
|
||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||
require(text() == L"prefix wrong " && !rows.empty() &&
|
||||
rows.back().outcome == L"recent-verification-mismatch",
|
||||
"diagnostic mismatch does not replace unverified text");
|
||||
auto details = rows.back().details;
|
||||
require(details.find(L"selection-mismatch") != details.npos &&
|
||||
details.find(L"Expected selection:\r\nother ") != details.npos &&
|
||||
details.find(L"Read back:\r\nwrong ") != details.npos,
|
||||
"failed verification includes expected and copied text in exported details");
|
||||
require(details.find(L"Copy begin number=") != details.npos &&
|
||||
details.find(L"Copy result number=") != details.npos &&
|
||||
details.find(L"Selection initial match=0") != details.npos &&
|
||||
details.find(L"SelectPrevious") != details.npos,
|
||||
"incident includes clipboard and selection stages");
|
||||
pasteBehavior = 3;
|
||||
prepare(L"=nhtdjuf", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
stage = 49;
|
||||
break;
|
||||
}
|
||||
case 49:
|
||||
{
|
||||
require(text() == L"=тревога" && pasteBehavior == 0,
|
||||
"native Edit retries one ignored paste only while original remains selected");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard");
|
||||
finish();
|
||||
break;
|
||||
}
|
||||
case 30:
|
||||
case 32:
|
||||
case 34:
|
||||
@@ -562,6 +635,61 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case 22:
|
||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||
app->settings.autoReplace = true;
|
||||
app->settings.autoReplacements = {{L"helo", L"hello"}};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"(Helo)! ", false);
|
||||
record(L"(Helo)! ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 23:
|
||||
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
|
||||
app->transform(ks::Undo);
|
||||
break;
|
||||
case 24:
|
||||
require(text() == L"(Helo)! ", "Undo restores explicit typo replacement");
|
||||
app->settings.autoReplace = false;
|
||||
app->settings.autoTypos = true;
|
||||
app->settings.autoWords[L"en"].push_back(L"kswitcher");
|
||||
// Use a personal target to avoid bundled dictionary ambiguity.
|
||||
prepare(L"kswithcer ", false);
|
||||
record(L"kswithcer ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 25:
|
||||
require(text() == L"kswitcher ", "dictionary typo correction replaces text in the current layout");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "typo correction restores clipboard");
|
||||
app->transform(ks::Undo);
|
||||
break;
|
||||
case 26:
|
||||
require(text() == L"kswithcer ", "Undo restores dictionary typo correction");
|
||||
record(L"kswithcer ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 27:
|
||||
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
||||
app->settings.autoReplace = true;
|
||||
app->settings.autoReplacements = {{L"tehh", L"the"}};
|
||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||
prepare(L"", false);
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
for (WORD key : std::wstring(L"TEHH X"))
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||
input.ki.dwExtraInfo = 123;
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"custom typo and following word submitted without a pause");
|
||||
}
|
||||
break;
|
||||
case 28:
|
||||
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
|
||||
app->settings.autoReplace = false;
|
||||
app->settings.autoTypos = false;
|
||||
if (typosOnly) { finish(); break; }
|
||||
stage = 30;
|
||||
break;
|
||||
}
|
||||
@@ -586,6 +714,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||||
typosOnly = argc > 1 && std::string(argv[1]) == "--typos-only";
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
auto root = std::filesystem::temp_directory_path() /
|
||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||
@@ -741,7 +870,7 @@ int main(int argc, char **argv)
|
||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||
deadline = GetTickCount64() + 30000;
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||
stage = 20;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||
stage = 30;
|
||||
|
||||
+274
-1
@@ -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,169 @@ 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);
|
||||
@@ -32,6 +254,57 @@ int main()
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
#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 partialConfig = config; partialConfig.keepLocalLayouts = true;
|
||||
saveSyncConfig(root, partialConfig);
|
||||
require(loadSyncConfig(root).keepLocalLayouts, "partial sync mode survives restart");
|
||||
saveSyncConfig(root, config);
|
||||
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
||||
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
||||
Settings local;
|
||||
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");
|
||||
require(parse(mergeSyncSettings({}, bytes(left), base))["snippets"].size() == 2,
|
||||
"first sync merges independent additions without choosing a whole profile");
|
||||
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
||||
{
|
||||
auto l = j, r = j;
|
||||
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||
l["autoCorrectNever"].push_back("localword");
|
||||
r["autoCorrectNever"].push_back("remoteword");
|
||||
auto plan = mergeSyncProfile({}, bytes(l), bytes(r));
|
||||
require(plan.conflicts.size() == 1 && parse(plan.local)["autoCorrectNever"].size() == 2 &&
|
||||
parse(plan.remote)["autoCorrectNever"].size() == 2,
|
||||
"first sync merges lists while preserving scalar conflicts");
|
||||
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
||||
"first sync never guesses between different scalar values");
|
||||
auto again = mergeSyncProfile(plan.base, plan.local, plan.remote);
|
||||
require(again.conflicts.size() == 1, "first-sync conflicts survive saving the ancestor");
|
||||
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {{plan.conflicts[0], false}});
|
||||
require(resolved.conflicts.empty() && parse(resolved.local)["autoCorrectMinLength"] == 5,
|
||||
"first-sync conflict can be resolved independently");
|
||||
}
|
||||
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
||||
{
|
||||
auto l = j, r = j;
|
||||
l["autoCorrectReplacements"]["helo"] = "hello";
|
||||
r["autoCorrectReplacements"]["wrold"] = "world";
|
||||
auto paired = mergeSyncSettings(base, bytes(l), bytes(r));
|
||||
require(parse(paired)["autoCorrectReplacements"].size() == 2, "independent replacement pairs merge");
|
||||
l = r = parse(paired);
|
||||
l["autoCorrectReplacements"].erase("helo");
|
||||
r["autoCorrectReplacements"]["wrold"] = "worlds";
|
||||
auto removed = mergeSyncSettings(paired, bytes(l), bytes(r));
|
||||
auto restored = restoreSyncSettings(removed, local, {});
|
||||
require(restored.autoReplacements.size() == 1 && restored.autoReplacements.at(L"wrold") == L"worlds",
|
||||
"replacement deletion merges with an independent edit and remains loadable");
|
||||
r["autoCorrectReplacements"]["helo"] = "help";
|
||||
require(mergeSyncProfile(paired, bytes(l), bytes(r)).conflicts.size() == 1,
|
||||
"replacement deletion versus edit is a conflict");
|
||||
auto legacy = j;
|
||||
for (auto key : {"autoCorrectReplace", "autoCorrectTypos", "autoCorrectReplacements"}) legacy.erase(key);
|
||||
auto migrated = restoreSyncSettings(bytes(legacy), local, {});
|
||||
require(!migrated.autoReplace && !migrated.autoTypos && migrated.autoReplacements.empty(),
|
||||
"older synchronized profiles default new typo modes off");
|
||||
}
|
||||
{
|
||||
auto l = j, r = j;
|
||||
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||
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;
|
||||
{
|
||||
auto remoteProfile = j;
|
||||
remoteProfile["layouts"] = Json::array({"win:00000422"});
|
||||
remoteProfile["layoutHotkeys"] = Json::array({{{"layoutId", "win:00000422"}, {"hotkey", "Ctrl+Alt+9"}}});
|
||||
remoteProfile["autoCorrectNever"].push_back("remoteword");
|
||||
FakeProvider partial;
|
||||
partial.remote = {3, bytes(remoteProfile)};
|
||||
auto validate = [&](const Bytes &b) { restoreSyncSettings(b, local, {}, true); };
|
||||
auto result = synchronize(partial, {}, base, SyncAction::Automatic, validate, {}, true);
|
||||
auto restored = restoreSyncSettings(result.settings, local, {}, true);
|
||||
require(restored.cycle == local.cycle && restored.direct == local.direct &&
|
||||
restored.autoNever == std::vector<std::wstring>{L"remoteword"},
|
||||
"partial sync restores other settings and keeps local layout configuration");
|
||||
auto next = sharedSyncSettings(restored);
|
||||
result = synchronize(partial, result.base, next, SyncAction::Automatic, validate, {}, true);
|
||||
require(parse(partial.remote.settings)["layouts"] == remoteProfile["layouts"] &&
|
||||
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
|
||||
"repeated partial sync never deletes unavailable server layouts or shortcuts");
|
||||
auto changed = parse(next); changed["autoCorrectMinLength"] = 7;
|
||||
result = synchronize(partial, result.base, bytes(changed), SyncAction::Upload, validate, {}, true);
|
||||
require(parse(partial.remote.settings)["autoCorrectMinLength"] == 7 &&
|
||||
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
|
||||
"explicit partial upload preserves server layouts too");
|
||||
auto downloaded = synchronize(partial, result.base, base, SyncAction::Download, validate, {}, true);
|
||||
auto downloadedSettings = restoreSyncSettings(downloaded.settings, local, {}, true);
|
||||
require(downloadedSettings.autoMinLength == 7 && downloadedSettings.cycle == local.cycle &&
|
||||
downloadedSettings.direct == local.direct, "partial Download applies other settings without replacing local layouts");
|
||||
try { restoreSyncSettings(bytes(remoteProfile), local, {}); require(false, "full sync must identify missing layouts"); }
|
||||
catch (const std::runtime_error &e) {
|
||||
std::string message = e.what();
|
||||
require(message.find("win:00000422") != message.npos && message.find("Keep this PC's layouts") != message.npos,
|
||||
"missing-layout error identifies IDs and offers partial sync");
|
||||
}
|
||||
}
|
||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
||||
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
||||
require(provider.writes == 1, "invalid data not uploaded");
|
||||
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;
|
||||
}
|
||||
}
|
||||
+104
-2
@@ -111,8 +111,8 @@ int main(int argc, char **argv)
|
||||
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
|
||||
"context expires after thirty seconds");
|
||||
diagnostics.record(L"oldest-context", false, 30600);
|
||||
for (int i = 0; i < 40; ++i)
|
||||
diagnostics.record(L"bounded-context", false, 30600 + i);
|
||||
for (int i = 0; i < 128; ++i)
|
||||
diagnostics.record(L"bounded-context", false, 30600);
|
||||
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
||||
"context buffer has a fixed size");
|
||||
diagnostics.record(L"disabled-context", false, 30800);
|
||||
@@ -192,11 +192,38 @@ 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");
|
||||
autoSettings.cycle = {L"en", L"uk"};
|
||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
||||
{
|
||||
auto options = autoSettings;
|
||||
options.autoTypos = true;
|
||||
auto typo = corrector.suggest(L"првиіт ", 2, layouts, options);
|
||||
require(typo && typo->text == L"привіт " && typo->target == 2,
|
||||
"bundled Ukrainian dictionary corrects a common transposition");
|
||||
}
|
||||
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
||||
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
||||
"dictionary corrects Ukrainian and retains trailing space");
|
||||
@@ -291,6 +318,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);
|
||||
@@ -307,6 +342,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"релізик"};
|
||||
@@ -337,6 +374,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");
|
||||
@@ -451,6 +504,45 @@ int main(int argc, char **argv)
|
||||
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
||||
require(correction && correction->text == L"hello ",
|
||||
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "spelling correction defaults off");
|
||||
options.autoTypos = true;
|
||||
for (auto typo : {L"hlelo ", L"helo ", L"helllo ", L"hezlo "})
|
||||
{
|
||||
correction = edited.suggest(typo, 0, layouts, options);
|
||||
require(correction && correction->text == L"hello " && correction->target == 0,
|
||||
"single transposition, deletion, insertion and substitution retain the source layout");
|
||||
}
|
||||
correction = edited.suggest(L"(Hlelo), ", 0, layouts, options);
|
||||
require(correction && correction->text == L"(Hello), ", "typos retain case and punctuation");
|
||||
for (auto text : {L"hello ", L"HLELO ", L"hLelo ", L"h3llo ", L"h.llo ", L"hlo ", L"hello@example.com "})
|
||||
require(!edited.suggest(text, 0, layouts, options), "known or unsafe typo input is unchanged");
|
||||
options.autoWords[L"en"] = {L"hella"};
|
||||
require(!edited.suggest(L"hell ", 0, layouts, options), "personal dictionary alternatives create ambiguity");
|
||||
options.autoWords.clear();
|
||||
options.autoReplacements = {{L"hlelo", L"greeting"}, {L"hello", L"greetings"}, {L"првиіт", L"привіт"}};
|
||||
options.autoReplace = true;
|
||||
correction = edited.suggest(L"Hlelo! ", 0, layouts, options);
|
||||
require(correction && correction->text == L"Greeting! ", "explicit replacements override spelling guesses");
|
||||
correction = edited.suggest(L"hello ", 0, layouts, options);
|
||||
require(correction && correction->text == L"greetings ", "explicit replacements can override known words");
|
||||
correction = edited.suggest(L"Првиіт ", 2, layouts, options);
|
||||
require(correction && correction->text == L"Привіт " && correction->target == 2,
|
||||
"Ukrainian replacements work without dictionary membership");
|
||||
options.autoNever = {L"hlelo"};
|
||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||
options.autoNever.clear();
|
||||
edited.reject(L"(Hlelo)! ", options);
|
||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
||||
options.autoReplace = false;
|
||||
options.autoWords[L"uk"] = {L"привіт"};
|
||||
correction = edited.suggest(L"првиіт ", 2, layouts, options);
|
||||
require(correction && correction->text == L"привіт ", "Ukrainian personal words are typo targets");
|
||||
options.cycle = {L"en"};
|
||||
require(!edited.suggest(L"првиіт ", 2, layouts, options), "disabled source layouts do not correct typos");
|
||||
put(L"en.txt", L"hello\t1\nhella\t99999\n");
|
||||
ks::AutoCorrector ambiguous;
|
||||
ambiguous.configure(folder, {L"en", L"uk"});
|
||||
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
||||
}
|
||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||
@@ -496,6 +588,8 @@ int main(int argc, char **argv)
|
||||
settings.writeLog = false;
|
||||
settings.elevatedAutostart = true;
|
||||
settings.autoCorrect = true;
|
||||
settings.autoReplace = settings.autoTypos = true;
|
||||
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
||||
settings.autoMaxRank = 10000;
|
||||
settings.autoMinLength = 5;
|
||||
settings.autoPrefixChars = L"(#";
|
||||
@@ -508,6 +602,14 @@ int main(int argc, char **argv)
|
||||
ks::saveSettings(temp / L"settings.json", settings);
|
||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
||||
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
||||
"typo options and Unicode pairs survive settings roundtrip");
|
||||
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}})
|
||||
{
|
||||
auto invalid = settings;
|
||||
invalid.autoReplacements = {{pair.first, pair.second}};
|
||||
rejects([&] { ks::validateSettings(invalid); }, "invalid replacement pairs rejected");
|
||||
}
|
||||
auto invalidWords = settings;
|
||||
invalidWords.autoWords[L"uk"] = {L"hello"};
|
||||
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
||||
|
||||
@@ -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