Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca553a5e7d | |||
| 55993c0f90 | |||
| 738c81dce2 | |||
| 22edece7cf | |||
| 908985d775 | |||
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 | |||
| c326db7dc5 |
@@ -0,0 +1,35 @@
|
||||
# Project instructions
|
||||
|
||||
## Mandatory Wiki updates for every release
|
||||
|
||||
A release is not complete until the published project Wiki reflects that release.
|
||||
Apply this rule to every release, including patch releases and bug-fix-only releases.
|
||||
|
||||
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
|
||||
|
||||
1. Confirm the release version from `CMakeLists.txt`, the release tag and
|
||||
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
|
||||
preserve the release channel rules in `README.md`.
|
||||
2. Update the latest-version information on the Wiki home pages and any other
|
||||
pages identifying the current release. Update release dates, download links
|
||||
and upgrade instructions where needed. Keep historical version references
|
||||
and compatibility bridge instructions when they are still accurate.
|
||||
3. Review the changes since the last release documented in the Wiki, not just
|
||||
the immediately preceding release. Bring the relevant feature pages up to
|
||||
date with the shipped behavior: new, changed and removed functionality,
|
||||
settings, shortcuts, defaults, limitations and troubleshooting. Update
|
||||
screenshots or examples when they no longer match the application.
|
||||
For bug-fix-only releases, check whether documented behavior or workarounds
|
||||
need correction; do not invent feature changes.
|
||||
4. Keep English and Ukrainian pages synchronized, including `Home` and
|
||||
`Home-uk`, and preserve working translation and navigation links.
|
||||
5. Publish the Wiki changes as part of the release workflow. Read back the
|
||||
published pages and verify the current version, feature descriptions,
|
||||
download links and translation links. A local Wiki edit alone is not enough.
|
||||
6. In the release completion report, link the updated Wiki pages and summarize
|
||||
the documentation changes. If Wiki publication or verification is blocked,
|
||||
explicitly report the outstanding step and do not claim the release workflow
|
||||
is complete.
|
||||
|
||||
Updating only the release notes or changing only the Wiki version number does
|
||||
not satisfy this rule; the current functionality must also be reviewed.
|
||||
+18
-4
@@ -1,14 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.10 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.29 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
target_link_libraries(kscore PUBLIC winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
@@ -17,6 +17,16 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
add_executable(KSwitcherSyncTests tests/sync.cpp)
|
||||
target_include_directories(KSwitcherSyncTests PRIVATE vendor)
|
||||
target_link_libraries(KSwitcherSyncTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherSyncTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherSyncTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
||||
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
@@ -39,7 +49,7 @@ endif()
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||
@@ -49,14 +59,18 @@ else()
|
||||
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
enable_testing()
|
||||
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||
|
||||
@@ -16,10 +16,15 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||
Double-click the tray icon to open Settings, the bold default menu item.
|
||||
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
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
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||
@@ -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,13 +167,47 @@ resize; circles keep equal width and height. Move shapes by their outlines, adju
|
||||
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
||||
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||
for shapes.
|
||||
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
||||
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
||||
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
||||
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
||||
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
||||
The wheel without Ctrl still adjusts the object under the cursor.
|
||||
|
||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||
after mouse release, nearly straight strokes become editable straight lines at their drawn angle,
|
||||
preserving endpoints, color, thickness, line style and arrowheads. Short strokes, broad curves and
|
||||
retraced strokes are not straightened. Other strokes receive light smoothing to reduce jitter.
|
||||
Confidently recognized closed outlines become
|
||||
rectangles, circles, ellipses, triangles or trapezoids (including tilted outlines), stored as shape objects.
|
||||
Drag either bounding-box corner handle to resize the entire recognized outline; circles keep equal
|
||||
width and height. Shapes can also be moved, recolored and cloned.
|
||||
Every shape has a round rotation handle above its outline. Drag it to rotate around the center;
|
||||
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
||||
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
||||
undo and redo it. The square handle still controls outline thickness.
|
||||
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
||||
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
||||
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
||||
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||
Oval fitting tolerates uneven contours and small closing gaps or overlaps; broad open arcs and
|
||||
repeated loops remain freehand strokes.
|
||||
Press **Ctrl+Z** once to restore the original stroke, again to remove it; **Ctrl+Y** reapplies the improvement.
|
||||
Small strokes, strokes linked to other objects, and any stroke drawn partly with Shift are left unchanged.
|
||||
Ambiguous shapes are only smoothed. To edit the resulting curve,
|
||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
||||
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||
While holding Shift, moving more than 50 image pixels away from the current line selects a new
|
||||
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
|
||||
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
||||
The window close button asks for confirmation before discarding changes that have not been saved or copied.
|
||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||
The shortcut is configurable in Settings under **Screen capture**.
|
||||
@@ -184,7 +227,15 @@ Click the notification or **Review update** to check the latest release and conf
|
||||
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||
Confirm the offered version to download and install it. The updater uses the existing
|
||||
Confirm the offered version to download and install it. A progress window shows
|
||||
download percentage and received/total size, package verification,
|
||||
installation and restart. After the new process confirms startup, that window shows the installed
|
||||
version and a Done button; it does not send a separate completion notification. If installation
|
||||
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
||||
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
||||
the currently installed build, so it becomes available for subsequent updates after installing it.
|
||||
|
||||
The updater uses the existing
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||
@@ -208,15 +259,27 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
## Implemented
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
||||
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||
history and cloud synchronization. Paste is not blindly retried.
|
||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
||||
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
||||
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
|
||||
and no Enter action is sent.
|
||||
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||
full-text search, pinning, deletion, full text viewing and image previews.
|
||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||
@@ -258,6 +321,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.
|
||||
|
||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||
|
||||
## Data and migration
|
||||
@@ -267,25 +340,45 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
||||
Only failures flush this context to `kswitcher.log`; flushed
|
||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
||||
Diagnostic stages stay in a memory buffer of at most 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
|
||||
errors are also logged when logging is enabled. This works in normal Release builds.
|
||||
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
||||
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
||||
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
||||
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
|
||||
original length and a truncation flag; context fields are limited to 512 UTF-16 code units.
|
||||
Structured summaries retain up to 4096 UTF-16 code units per text field and mark truncation.
|
||||
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
||||
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
||||
record the process, window, input version, and repeated attempts within ten seconds without new
|
||||
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
||||
An unchanged result containing no letters, or a direct command
|
||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
||||
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
||||
checks the focused editor without moving the caret, changing selection, or copying again.
|
||||
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
|
||||
**Paste not verified**. Unsupported/protected controls, changed focus, and readback limits are
|
||||
explicit verification reasons, never proof of success. Readback is bounded to 4096 characters;
|
||||
provider-specific character units or delayed editor updates may prevent verification.
|
||||
With logging disabled, this additional accessibility check is skipped.
|
||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
|
||||
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
|
||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
||||
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
|
||||
Select a row to inspect original, expected and read-back text, verification status and stages
|
||||
in the panel below. **Copy selected** or Ctrl+C copies the selected rows with their details;
|
||||
Ctrl+A selects all rows. Older unstructured log entries remain readable. Viewing saved logs
|
||||
also works when logging is disabled.
|
||||
**Clear log** clears only the selected current or previous file and refreshes the table.
|
||||
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||
|
||||
@@ -306,6 +399,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:
|
||||
@@ -325,13 +531,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
|
||||
@@ -346,6 +579,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.
|
||||
@@ -355,12 +590,15 @@ Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. En
|
||||
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||
files before replacing dictionaries with a new distribution.
|
||||
|
||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
|
||||
pending work. During the replacement itself, key presses and releases are queued and replayed after
|
||||
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||
key presses and releases; no typing pause is required. Queued input is replayed after
|
||||
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
||||
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
||||
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
||||
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
||||
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
||||
Different valid spellings remain ambiguous and are left unchanged.
|
||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||
does not count toward this minimum; the maximum word length is 32 letters.
|
||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||
@@ -434,6 +672,13 @@ against a staged build. It briefly opens real selection/editor windows without c
|
||||
|
||||
### Release channel rule
|
||||
|
||||
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
|
||||
the latest version and relevant feature documentation in both English and Ukrainian.
|
||||
Review all changes since the last documented release, update affected instructions,
|
||||
shortcuts, settings and screenshots, and verify the published pages and links.
|
||||
The release workflow is incomplete until this is done. Follow the mandatory
|
||||
[Wiki release checklist](AGENTS.md#mandatory-wiki-updates-for-every-release).
|
||||
|
||||
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
||||
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||
|
||||
Binary file not shown.
+13
-9
@@ -1,25 +1,29 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.10 — 2026-10-29
|
||||
## v0.6.29 — 2026-11-02
|
||||
|
||||
## Changes
|
||||
- Preserve typing during automatic layout correction: queue key presses and releases until replacement, clipboard restoration and layout switching finish. Preserve modifiers and Backspace; collapse temporary selection before replay when a correction is rejected.
|
||||
- Add My dictionary fields for English, Ukrainian and Russian in Auto-correction options. Enter correct words separated by commas or new lines; save them with settings without editing dictionary files, sorting entries or assigning ranks.
|
||||
- Accept unsorted dictionary files and duplicate entries, retaining the lowest rank for duplicates. Include Ukrainian `реліз` in the bundled vocabulary so `релыз` can be corrected when Russian and Ukrainian are enabled.
|
||||
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item.
|
||||
- Clearing requires confirmation, with **No** selected by default. It removes all saved entries, including pinned entries and entries hidden by the current search. Cancelling leaves history unchanged.
|
||||
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action.
|
||||
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management.
|
||||
|
||||
## Validation
|
||||
Native regression checks cover personal words, settings persistence, language validation, dictionary ordering and duplicate ranks. Input/UI checks cover personal-word entry and normalization. Native editor integration covers typing with Shift and Backspace during successful and rejected corrections. Release checks also cover screenshot UI, isolated notification registration and updater validation.
|
||||
The final build passed isolated checks for the new button, cancellation, confirmed deletion of pinned/search-hidden entries and persistence after reloading the history store. 48 updater regression checks passed; all executable versions and release-manifest hashes were verified. The full desktop-interaction suite was not rerun for this focused patch.
|
||||
|
||||
## Known limitations
|
||||
Existing asynchronous-editor selection/clipboard limitations remain; this release does not fix the reported ChatGPT selection mismatch. Dictionary typo correction and OpenBao synchronization remain experimental as documented in v0.6.28.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. Existing customized dictionary files may be preserved by the updater; use My dictionary to add missing words without replacing those files.
|
||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package 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:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test -Integration
|
||||
.\build.ps1 -Stage -Test
|
||||
.\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 @@
|
||||
36966eb0b2b0af47c245aec6337a16703e0fbdca4d3eda7a8f25fa14a20f347e KSwitcher-win-x64.zip
|
||||
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -10,11 +10,13 @@ $outDir = Join-Path $root 'build'
|
||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
||||
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||
Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||
}
|
||||
@@ -27,11 +29,11 @@ try {
|
||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||
@@ -39,11 +41,19 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||
& .\build\KSwitcherSyncTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
||||
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||
& .\build\KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||
& .\build\KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||
@@ -51,6 +61,8 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe --save-only
|
||||
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||
try {
|
||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
@@ -61,15 +73,17 @@ try {
|
||||
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
& $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,10,0
|
||||
PRODUCTVERSION 0,6,10,0
|
||||
FILEVERSION 0,6,29,0
|
||||
PRODUCTVERSION 0,6,29,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.10"
|
||||
VALUE "FileVersion", "0.6.29"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+213
-3
@@ -3,10 +3,192 @@
|
||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||
[switch]$ShowProgress,
|
||||
[switch]$Library
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$script:progressWindow = $null
|
||||
|
||||
function Start-UpdateProgress {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
if (!('KSwitcherUpdateProgress' -as [type])) {
|
||||
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
public sealed class UpdateMeter : Control {
|
||||
public int Percent = -1;
|
||||
private int position;
|
||||
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
||||
public UpdateMeter() {
|
||||
DoubleBuffered = true;
|
||||
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
||||
timer.Start();
|
||||
}
|
||||
protected override void OnPaint(PaintEventArgs e) {
|
||||
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
||||
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
||||
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
||||
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
||||
}
|
||||
}
|
||||
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
||||
}
|
||||
public sealed class UpdateSteps : Control {
|
||||
public int Current = -1;
|
||||
public bool Complete;
|
||||
public UpdateSteps() { DoubleBuffered = true; }
|
||||
protected override void OnPaint(PaintEventArgs e) {
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
string[] names = {"Download","Verify","Install","Restart"};
|
||||
for(int i=0;i<4;i++) {
|
||||
int x = i*(Width-80)/3;
|
||||
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
||||
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
||||
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
||||
using(var brush = new SolidBrush(color)) {
|
||||
if(Complete || i<Current) {
|
||||
e.Graphics.FillEllipse(brush,x,0,16,16);
|
||||
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
||||
} else {
|
||||
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
||||
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
||||
}
|
||||
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public sealed class KSwitcherUpdateProgress : IDisposable {
|
||||
private Form form;
|
||||
private Label stage, detail, percentage, footer;
|
||||
private UpdateMeter progress;
|
||||
private UpdateSteps steps;
|
||||
private Button done, close;
|
||||
private bool closing;
|
||||
private Exception failure;
|
||||
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
||||
private readonly Thread thread;
|
||||
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
||||
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
||||
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
||||
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
||||
public KSwitcherUpdateProgress() {
|
||||
thread = new Thread(delegate() {
|
||||
try {
|
||||
Application.EnableVisualStyles();
|
||||
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
||||
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
||||
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
||||
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
||||
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
||||
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
||||
};
|
||||
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
||||
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
||||
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
||||
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
||||
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
||||
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
||||
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
||||
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
||||
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
||||
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
||||
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
||||
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
||||
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
||||
bottom.Controls.AddRange(new Control[]{footer,done});
|
||||
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
||||
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
||||
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
||||
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
||||
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
||||
Application.Run(form);
|
||||
} catch(Exception e) { failure = e; ready.Set(); }
|
||||
});
|
||||
thread.IsBackground = true;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
||||
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
||||
}
|
||||
public void Report(string title, string text, int percent) {
|
||||
if (form == null || form.IsDisposed) return;
|
||||
form.BeginInvoke((Action)delegate {
|
||||
stage.Text = title; detail.Text = text;
|
||||
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
||||
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
||||
int separator = text.IndexOf("% - ");
|
||||
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
||||
if(title.StartsWith("Downloading")) steps.Current=0;
|
||||
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
||||
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
||||
else if(title.StartsWith("Restarting")) steps.Current=3;
|
||||
progress.Invalidate(); steps.Invalidate();
|
||||
});
|
||||
}
|
||||
public void SavePreview(string path) {
|
||||
form.Invoke((Action)delegate {
|
||||
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
||||
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
||||
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
||||
}
|
||||
});
|
||||
}
|
||||
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
||||
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
||||
public void Finish(string title, string text, bool success) {
|
||||
form.Invoke((Action)delegate {
|
||||
closing = true;
|
||||
stage.Text = title; detail.Text = text;
|
||||
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
||||
steps.Complete = success;
|
||||
if(!success) {
|
||||
detail.Visible = false; steps.Visible = false;
|
||||
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
||||
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
||||
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
||||
}
|
||||
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
||||
close.Enabled = true; done.Visible = true;
|
||||
form.AcceptButton = done; form.CancelButton = done;
|
||||
progress.Invalidate(); steps.Invalidate();
|
||||
});
|
||||
}
|
||||
public void WaitForClose() { thread.Join(); }
|
||||
public void Dispose() {
|
||||
if (form != null && !form.IsDisposed)
|
||||
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
||||
thread.Join(3000);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
||||
}
|
||||
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
||||
if ($script:progressWindow) {
|
||||
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
||||
catch { Write-Warning 'Could not refresh update progress.' }
|
||||
}
|
||||
}
|
||||
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
||||
if ($script:progressWindow) {
|
||||
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
||||
catch { Write-Warning 'Could not display the update result.' }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-StableVersion([string]$Tag) {
|
||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||
}
|
||||
}
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$request = [Net.HttpWebRequest]::Create($Url)
|
||||
$request.AllowAutoRedirect = $false
|
||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
$buffer = New-Object byte[] 65536
|
||||
$total = 0L
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
$lastProgress = [DateTime]::MinValue
|
||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||
$total += $count
|
||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||
$outputStream.Write($buffer,0,$count)
|
||||
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
||||
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
||||
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
||||
Show-UpdateProgress $ProgressLabel $detail $percent
|
||||
$lastProgress = [DateTime]::UtcNow
|
||||
}
|
||||
}
|
||||
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
||||
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
||||
} finally {
|
||||
if ($outputStream) { $outputStream.Dispose() }
|
||||
if ($inputStream) { $inputStream.Dispose() }
|
||||
@@ -220,8 +411,9 @@ function Write-Result($Value) {
|
||||
if ($Library) { return }
|
||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||
$changed = [Collections.Generic.List[string]]::new()
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||
try {
|
||||
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||
$current = Get-StableVersion $CurrentVersion
|
||||
if (!$current) { throw 'Invalid installed version.' }
|
||||
if ($Mode -eq 'Install') {
|
||||
@@ -242,25 +434,35 @@ try {
|
||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||
Receive-File $release.zip $archive 50MB
|
||||
Show-UpdateProgress 'Downloading update' $release.version 0
|
||||
Receive-File $release.zip $archive 50MB 'Downloading update'
|
||||
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
||||
Receive-File $release.checksum $checksum 64KB
|
||||
Assert-Checksum $archive $checksum
|
||||
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
$parentExited = $true
|
||||
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||
Install-Files $stage $InstallDirectory $backup $changed
|
||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||
$success = $true
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||
}
|
||||
} catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($parentExited) {
|
||||
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||
try {
|
||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||
Undo-Files $InstallDirectory $backup $changed
|
||||
@@ -273,9 +475,13 @@ try {
|
||||
}
|
||||
Write-Result @{state='error';error=$message}
|
||||
if ($ready -and $ready.WaitOne(0)) {
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||
} else {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||
}
|
||||
}
|
||||
exit 1
|
||||
} finally {
|
||||
if ($ready) { $ready.Dispose() }
|
||||
@@ -291,4 +497,8 @@ try {
|
||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($script:progressWindow) {
|
||||
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
||||
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
||||
}
|
||||
}
|
||||
|
||||
+339
-82
@@ -1,7 +1,10 @@
|
||||
#include "app.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "configuration.h"
|
||||
#include "diagnostics.h"
|
||||
#include "paste_probe.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
@@ -9,9 +12,21 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
#include "app_sync.inl"
|
||||
App *App::current = nullptr;
|
||||
namespace
|
||||
{
|
||||
const char *transformCommandName(int command)
|
||||
{
|
||||
return command == Toggle ? "Convert / cycle" : command >= Direct ? "Convert / direct" :
|
||||
command == Case ? "Change case" : command == Translit ? "Transliterate" :
|
||||
command == Undo ? "Undo" : "Automatic / snippet";
|
||||
}
|
||||
ULONG_PTR snippetReplayMarker()
|
||||
{
|
||||
static const char marker{};
|
||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||
}
|
||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||
{
|
||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||
@@ -58,6 +73,8 @@ class Transaction
|
||||
Clip snapshot;
|
||||
DWORD expected{};
|
||||
bool captured{};
|
||||
mutable bool invalidLogged{};
|
||||
unsigned copyNumber{};
|
||||
std::wstring adopted;
|
||||
|
||||
public:
|
||||
@@ -93,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()
|
||||
{
|
||||
@@ -111,7 +138,13 @@ class Transaction
|
||||
{
|
||||
check();
|
||||
adopted.clear();
|
||||
app.ownSequence = expected = writeClipboard(app.window, c);
|
||||
// These are temporary transaction contents, not a user's clipboard entry.
|
||||
// In particular, do not invite the Windows history service to read our
|
||||
// sentinel while the target editor is trying to open the clipboard.
|
||||
auto temporary = c;
|
||||
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
|
||||
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
|
||||
app.ownSequence = expected = writeClipboard(app.window, temporary);
|
||||
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
||||
// Never adopt that writer's sequence as our own and paste its content.
|
||||
if (GetClipboardOwner() != app.window)
|
||||
@@ -123,16 +156,22 @@ 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");
|
||||
return {};
|
||||
}
|
||||
ULONGLONG deadline = GetTickCount64() + 2000;
|
||||
ULONGLONG retryAt = GetTickCount64() + 250;
|
||||
unsigned attempts = 1;
|
||||
DWORD changed = before;
|
||||
ULONGLONG stable = 0;
|
||||
bool changedAgain = false;
|
||||
@@ -143,16 +182,40 @@ 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();
|
||||
}
|
||||
if (changed != before && GetTickCount64() - stable >= 60)
|
||||
break;
|
||||
// Native editors can silently drop Ctrl+C if another process has
|
||||
// the clipboard open. Retry only while our sentinel is untouched;
|
||||
// never overwrite a copy result or another application's content.
|
||||
if (changed == before && attempts < 3 && GetTickCount64() >= retryAt &&
|
||||
!GetOpenClipboardWindow())
|
||||
{
|
||||
check();
|
||||
if (!valid() || GetClipboardSequenceNumber() != before)
|
||||
return {};
|
||||
if (!chord(L'C', MOD_CONTROL))
|
||||
return {};
|
||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
|
||||
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||
++attempts;
|
||||
retryAt = GetTickCount64() + 250;
|
||||
}
|
||||
Sleep(10);
|
||||
}
|
||||
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 {};
|
||||
@@ -163,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)
|
||||
@@ -191,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); }))
|
||||
@@ -202,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");
|
||||
@@ -211,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)
|
||||
{
|
||||
@@ -248,6 +357,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||
}
|
||||
setLoggingEnabled(settings.writeLog);
|
||||
if (settings.autoCorrect)
|
||||
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
settings.autoCorrect = false;
|
||||
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
|
||||
}
|
||||
try
|
||||
{
|
||||
history = std::make_unique<History>(data / L"history");
|
||||
@@ -260,6 +376,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)
|
||||
@@ -390,7 +507,7 @@ void App::addTray()
|
||||
icon.uCallbackMessage = TrayMessage;
|
||||
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||
wcscpy_s(icon.szTip, L"KSwitcher — keyboard layouts & clipboard");
|
||||
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
||||
Shell_NotifyIconW(NIM_ADD, &icon);
|
||||
icon.uVersion = NOTIFYICON_VERSION_4;
|
||||
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
||||
@@ -414,15 +531,23 @@ 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);
|
||||
validateTesseract(s);
|
||||
if (s.autoCorrect)
|
||||
{
|
||||
AutoCorrector probe;
|
||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
}
|
||||
if (!registerHotkeys(s))
|
||||
{
|
||||
@@ -454,14 +579,17 @@ void App::applySettings(const Settings &s)
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
settings = s;
|
||||
autoPredictor = std::move(predictor);
|
||||
setLoggingEnabled(s.writeLog);
|
||||
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));
|
||||
}
|
||||
@@ -485,6 +613,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);
|
||||
}
|
||||
@@ -514,9 +643,11 @@ 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();
|
||||
}
|
||||
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
{
|
||||
if (ownKeyboardInput(k))
|
||||
@@ -530,6 +661,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);
|
||||
@@ -557,9 +691,33 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
return;
|
||||
}
|
||||
run.record(ch, fg, layout, GetTickCount64());
|
||||
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||
if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() &&
|
||||
mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||
run.keys.size() <= 41 && !busy && !run.overflow)
|
||||
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
|
||||
{
|
||||
for (int source = 0; source < (int)layouts.size(); ++source)
|
||||
if (layouts[source].handle == layout)
|
||||
{
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : run.keys)
|
||||
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
|
||||
if (autoPredictor.suggest(predicted, source, layouts, settings))
|
||||
{
|
||||
// Reserve the transaction before the next physical key arrives.
|
||||
// The delimiter itself passes through; subsequent input is queued.
|
||||
typingTarget = fg;
|
||||
autoPending = true;
|
||||
busy = true;
|
||||
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
|
||||
{
|
||||
typingTarget = nullptr;
|
||||
autoPending = false;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (busy || settings.snippets.empty())
|
||||
return;
|
||||
auto keys = lower(run.keys);
|
||||
@@ -587,7 +745,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
for (auto &trigger : variants)
|
||||
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
||||
{
|
||||
PostMessageW(window, SnippetMessage, (WPARAM)i, 0);
|
||||
// Keep subsequent typing out of the selection/paste transaction.
|
||||
typingTarget = fg;
|
||||
snippetPending = (int)i;
|
||||
snippetTyping = true;
|
||||
busy = true;
|
||||
if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load()))
|
||||
{
|
||||
typingTarget = nullptr;
|
||||
snippetPending = -1;
|
||||
snippetTyping = false;
|
||||
busy = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -661,12 +830,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
bool App::beginTypingDeferral(HWND target, uint64_t version)
|
||||
bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet)
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||
return false;
|
||||
typingTarget = target;
|
||||
snippetTyping = snippet;
|
||||
return true;
|
||||
}
|
||||
void App::replayTyping()
|
||||
@@ -677,7 +847,13 @@ void App::replayTyping()
|
||||
std::lock_guard lock(stateMutex);
|
||||
target = typingTarget;
|
||||
typingTarget = nullptr;
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
inputs.swap(deferredTyping);
|
||||
if (snippetTyping)
|
||||
for (auto &input : inputs)
|
||||
input.ki.dwExtraInfo = snippetReplayMarker();
|
||||
snippetTyping = false;
|
||||
}
|
||||
busy = false;
|
||||
if (inputs.empty())
|
||||
@@ -721,8 +897,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)
|
||||
@@ -793,12 +972,30 @@ void App::transform(int command, int snippet)
|
||||
L" version=" + std::to_wstring(currentVersion) +
|
||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||
}
|
||||
if (busy.exchange(true))
|
||||
const bool preDeferred = (command == AutoCorrect && autoPending) ||
|
||||
(snippet >= 0 && snippetPending == snippet);
|
||||
if (busy.exchange(true) && !preDeferred)
|
||||
{
|
||||
if (manual)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
||||
{
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy");
|
||||
try
|
||||
{
|
||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
|
||||
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
|
||||
{"elapsedMs", GetTickCount64() - requestedAt}};
|
||||
diagnosticOperation(record.dump());
|
||||
}
|
||||
catch (...) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preDeferred)
|
||||
{
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
}
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -813,7 +1010,7 @@ void App::transform(int command, int snippet)
|
||||
version = inputVersion;
|
||||
}
|
||||
enqueue(
|
||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
|
||||
{
|
||||
struct BusyReset
|
||||
{
|
||||
@@ -827,10 +1024,10 @@ void App::transform(int command, int snippet)
|
||||
else
|
||||
b = false;
|
||||
}
|
||||
} reset{busy, window};
|
||||
} reset{busy, window, preDeferred};
|
||||
auto start = GetTickCount64();
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
std::wstring text, result, expected;
|
||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||
if (manual)
|
||||
{
|
||||
@@ -855,21 +1052,35 @@ void App::transform(int command, int snippet)
|
||||
bool enabled;
|
||||
std::wstring &reason;
|
||||
std::wstring &text, &result, &expected;
|
||||
std::wstring &actual, &verification;
|
||||
int &hint, &to;
|
||||
int command;
|
||||
const std::vector<Layout> &layouts;
|
||||
~Audit()
|
||||
{
|
||||
if (enabled)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
|
||||
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
|
||||
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
|
||||
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
|
||||
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
|
||||
L" result=" + diagnosticText(result),
|
||||
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
||||
reason != L"unchanged-expected");
|
||||
try
|
||||
{
|
||||
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
|
||||
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
|
||||
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
|
||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
|
||||
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
||||
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
|
||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
||||
{"expected", bounded(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));
|
||||
}
|
||||
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
|
||||
catch (...) {} // Logging must never abort a completed input transaction.
|
||||
}
|
||||
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
|
||||
actual, verification, hint, to, command, available};
|
||||
try
|
||||
{
|
||||
auto valid = [&]
|
||||
@@ -905,14 +1116,15 @@ void App::transform(int command, int snippet)
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : recent.keys)
|
||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||
expected = predicted;
|
||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||
!autoCorrectionAllowed(target, config) || !valid())
|
||||
return;
|
||||
}
|
||||
reason = L"clipboard-snapshot";
|
||||
if (command == AutoCorrect)
|
||||
if (command == AutoCorrect || snippet >= 0)
|
||||
{
|
||||
if (!beginTypingDeferral(target, version))
|
||||
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||
return;
|
||||
reset.deferred = true;
|
||||
}
|
||||
@@ -944,9 +1156,12 @@ void App::transform(int command, int snippet)
|
||||
autoSelected = true;
|
||||
Sleep(25);
|
||||
text = tx.copy();
|
||||
// Some editors first select only ") ". Extend by whole-word
|
||||
// boundaries, then still verify against the complete tracked run.
|
||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
||||
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||
// suffix of the tracked input, and still require a complete match.
|
||||
for (int i = 0; i < 3 && tx.valid() &&
|
||||
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
|
||||
(autoClosingSelection(text, config) ||
|
||||
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
|
||||
{
|
||||
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||
break;
|
||||
@@ -982,6 +1197,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;
|
||||
@@ -1004,13 +1221,11 @@ void App::transform(int command, int snippet)
|
||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||
if (!tx.select(sn.trigger.size()))
|
||||
return;
|
||||
autoSelected = true;
|
||||
reason = L"snippet-verification-mismatch";
|
||||
text = tx.copy();
|
||||
if (tail.match(text, available, physicalLayout()) < 0)
|
||||
{
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
result = parseTemplate(sn.text).text;
|
||||
automatic = true;
|
||||
}
|
||||
@@ -1018,12 +1233,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);
|
||||
@@ -1035,11 +1254,14 @@ 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";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
@@ -1082,16 +1304,13 @@ void App::transform(int command, int snippet)
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
int source = detect(text, available, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
|
||||
to = nextConversionLayout(text, available, cycle, hint);
|
||||
}
|
||||
result = convertTo(text, available, to, hint);
|
||||
}
|
||||
}
|
||||
bool sent = false;
|
||||
PasteProbe probe(config.writeLog && manual && result != text, text);
|
||||
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||
L" changed=" + std::to_wstring(result != text));
|
||||
if (result != text)
|
||||
@@ -1102,10 +1321,27 @@ void App::transform(int command, int snippet)
|
||||
autoSelected = false;
|
||||
reason = result == text ? L"conversion-unchanged" :
|
||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||
if (result == text &&
|
||||
(std::none_of(text.begin(), text.end(), letter) ||
|
||||
(command >= Direct && detect(text, available, hint) == to)))
|
||||
reason = L"unchanged-expected";
|
||||
if (manual && result == text)
|
||||
{
|
||||
reason = L"Conversion unchanged";
|
||||
verification = L"calculated-result-equals-original";
|
||||
}
|
||||
if (sent && manual && config.writeLog)
|
||||
{
|
||||
verification = probe.status;
|
||||
if (tx.valid())
|
||||
{
|
||||
auto observed = probe.read((int)caretSteps(result));
|
||||
verification = probe.status;
|
||||
if (observed && tx.valid())
|
||||
{
|
||||
actual = *observed;
|
||||
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
||||
}
|
||||
else reason = L"Paste not verified";
|
||||
}
|
||||
else verification = L"context-changed-after-paste";
|
||||
}
|
||||
if (sent && !tx.valid())
|
||||
reason = L"context-changed-after-paste";
|
||||
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||
@@ -1123,12 +1359,22 @@ void App::transform(int command, int snippet)
|
||||
if (snippet >= 0 && sent && tx.valid())
|
||||
{
|
||||
auto t = parseTemplate(config.snippets[snippet].text);
|
||||
if (!t.suffix.empty() && tx.select(caretSteps(t.suffix)))
|
||||
if (!t.suffix.empty())
|
||||
{
|
||||
if (!tx.select(caretSteps(t.suffix))) return;
|
||||
auto suffix = tx.copy();
|
||||
if (tx.valid())
|
||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
||||
if (!tx.valid()) return;
|
||||
if (newlines(suffix) != newlines(t.suffix))
|
||||
{
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
if (!chord(VK_LEFT)) return;
|
||||
}
|
||||
// Action markers run after insertion and cursor placement,
|
||||
// before any physical typing buffered during expansion.
|
||||
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
||||
if (!chord(VK_RETURN)) break;
|
||||
}
|
||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||
{
|
||||
@@ -1275,28 +1521,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();
|
||||
@@ -1387,7 +1633,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
|
||||
updateWorker = std::thread([this, interactive, install, version]
|
||||
{
|
||||
auto result = std::make_unique<UpdateResult>();
|
||||
try { *result = runUpdate(install, version); }
|
||||
try { *result = runUpdate(install, version, interactive); }
|
||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||
result->interactive = interactive;
|
||||
result->install = install;
|
||||
@@ -1476,13 +1722,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->captureHistory();
|
||||
return 0;
|
||||
case AutoCorrectMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->pendingAutoVersion = (uint64_t)wp;
|
||||
SetTimer(hwnd, 3, 250, nullptr);
|
||||
if (app->autoPending)
|
||||
{
|
||||
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||
app->transform(AutoCorrect);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
case SnippetMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
if (app->snippetPending == (int)wp)
|
||||
{
|
||||
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
|
||||
app->transform(0, (int)wp);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
case HistoryMessage:
|
||||
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
||||
@@ -1504,7 +1759,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_COPYDATA:
|
||||
{
|
||||
auto *data = (COPYDATASTRUCT *)lp;
|
||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
|
||||
{
|
||||
if (app->installingUpdate) return FALSE;
|
||||
return configurationRequest(*app, (HWND)wp, *data);
|
||||
@@ -1518,6 +1773,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.
|
||||
@@ -1534,13 +1794,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->dispatch(20);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 3)
|
||||
{
|
||||
KillTimer(hwnd, 3);
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
app->transform(AutoCorrect);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 2)
|
||||
{
|
||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||
@@ -1627,6 +1880,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())
|
||||
@@ -1635,8 +1889,8 @@ int App::execute()
|
||||
SetTimer(window, 2, 10000, nullptr);
|
||||
else
|
||||
SetTimer(window, 5, 5000, nullptr);
|
||||
if (keyboardHook && mouseHook)
|
||||
confirmUpdatedStartup(updateStartupEvent);
|
||||
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
try { registerScreenshotNotifications(); }
|
||||
@@ -1651,6 +1905,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>
|
||||
@@ -38,6 +39,7 @@ class App
|
||||
Settings settings;
|
||||
std::unique_ptr<History> history;
|
||||
AutoCorrector autoCorrector;
|
||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<uint64_t> inputVersion{0};
|
||||
@@ -70,12 +72,16 @@ class App
|
||||
void applySettings(const Settings &);
|
||||
void reloadLayouts();
|
||||
void clearHistory();
|
||||
void clearLog(bool previous);
|
||||
void setHotkeyCapture(HWND);
|
||||
bool isHotkey(DWORD, UINT);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
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:
|
||||
@@ -94,12 +100,21 @@ 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 &){};
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||
ULONGLONG previousAttemptAt{};
|
||||
HWND previousAttemptWindow{};
|
||||
@@ -107,8 +122,11 @@ class App
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
int snippetPending = -1;
|
||||
bool snippetTyping{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t);
|
||||
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||
void replayTyping();
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
@@ -116,6 +134,7 @@ class App
|
||||
};
|
||||
void showHistory(App &, bool visible = true);
|
||||
void launchScreenshot(App &);
|
||||
void launchScreenshotEditor(App &, const Clip &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
void showLogs(App &);
|
||||
|
||||
@@ -0,0 +1,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; }
|
||||
}
|
||||
+80
-4
@@ -152,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
suffix.insert(suffix.begin(), word.back());
|
||||
word.pop_back();
|
||||
}
|
||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||||
// Validate letters and capitalization after conversion, not before it.
|
||||
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||||
suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
return {};
|
||||
for (auto &never : settings.autoNever)
|
||||
if (lower(never) == lower(word))
|
||||
@@ -160,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);
|
||||
@@ -172,7 +194,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
if (d.rank(word) || personal(language, word))
|
||||
return {};
|
||||
std::optional<AutoSuggestion> candidate;
|
||||
int matches = 0;
|
||||
std::optional<std::wstring> matchedText;
|
||||
auto priority = [&](int layout)
|
||||
{
|
||||
auto language = layoutLanguage(layouts[layout]);
|
||||
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||||
};
|
||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||
{
|
||||
if (i == source || !layouts[i].convertible ||
|
||||
@@ -187,12 +214,61 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||
if (!rank)
|
||||
continue;
|
||||
if (++matches > 1)
|
||||
if (matchedText && *matchedText != converted)
|
||||
return {};
|
||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
||||
matchedText = converted;
|
||||
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||||
(!candidate || priority(i) < priority(candidate->target)))
|
||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||
}
|
||||
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)
|
||||
|
||||
+26
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
try
|
||||
{
|
||||
if (wp == 21) showSnippets(*app);
|
||||
else if (wp == 22) showLogs(*app);
|
||||
else showSettings(*app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
@@ -80,12 +82,14 @@ 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()
|
||||
{
|
||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||
if (logWindow) DestroyWindow(logWindow);
|
||||
if (window) DestroyWindow(window);
|
||||
current = nullptr;
|
||||
}
|
||||
@@ -107,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;
|
||||
@@ -120,9 +131,9 @@ void App::setHotkeyCapture(HWND field)
|
||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||
}
|
||||
}
|
||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
||||
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||
|
||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||
{
|
||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||
@@ -140,7 +151,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||
AllowSetForegroundWindow(pid);
|
||||
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
||||
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
||||
return 0;
|
||||
}
|
||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
@@ -169,17 +180,25 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||
if (snippets) showSnippets(app); else showSettings(app);
|
||||
setLoggingEnabled(app.settings.writeLog);
|
||||
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
||||
MSG message;
|
||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
{
|
||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||
{
|
||||
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;
|
||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
@@ -193,8 +212,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||
LocalFree(args);
|
||||
result = ks::runConfigurator(instance, snippets);
|
||||
result = ks::runConfigurator(instance, snippets, logs);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
|
||||
+26
-2
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||
}
|
||||
void launchConfiguration(App &app, bool snippets)
|
||||
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
@@ -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.");
|
||||
@@ -80,6 +99,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
case ConfigLogs:
|
||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||
break;
|
||||
case ConfigClearLog:
|
||||
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
||||
throw std::runtime_error("Invalid log selection.");
|
||||
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
||||
break;
|
||||
default: return FALSE;
|
||||
}
|
||||
ok = true;
|
||||
|
||||
+4
-2
@@ -5,9 +5,11 @@ namespace ks
|
||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||
ConfigReply = ConfigRead + 6;
|
||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||
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);
|
||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||
}
|
||||
|
||||
+39
-1
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
|
||||
}
|
||||
return best < 0 ? text : convert(text, layouts[best], to);
|
||||
}
|
||||
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const std::vector<int> &cycle, int hint)
|
||||
{
|
||||
if (cycle.empty())
|
||||
return -1;
|
||||
int source = detect(text, layouts, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||
// Compare every candidate with the original input, never an intermediate conversion.
|
||||
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||
{
|
||||
int target = cycle[(start + offset) % cycle.size()];
|
||||
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||
return target;
|
||||
}
|
||||
return cycle[start];
|
||||
}
|
||||
std::wstring cycleCase(const std::wstring &text)
|
||||
{
|
||||
if (text == upper(text))
|
||||
@@ -261,7 +281,17 @@ Template parseTemplate(const std::wstring &text)
|
||||
size_t cursor = std::wstring::npos;
|
||||
for (size_t i = 0; i < text.size();)
|
||||
{
|
||||
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||
if (text.compare(i, 9, L"{{enter}}") == 0)
|
||||
{
|
||||
t.text += L"{enter}";
|
||||
i += 9;
|
||||
}
|
||||
else if (text.compare(i, 7, L"{enter}") == 0)
|
||||
{
|
||||
++t.enters;
|
||||
i += 7;
|
||||
}
|
||||
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||
{
|
||||
t.text += L"{cursor}";
|
||||
i += 10;
|
||||
@@ -449,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
||||
keys.pop_back();
|
||||
at = now;
|
||||
}
|
||||
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||
return -1;
|
||||
Run tail = *this;
|
||||
tail.keys = keys.substr(keys.size() - text.size());
|
||||
return tail.match(text, layouts, physical);
|
||||
}
|
||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.size() != keys.size() || overflow)
|
||||
|
||||
+10
@@ -24,16 +24,22 @@ std::wstring upper(std::wstring text);
|
||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||
const std::vector<int> &cycle, int hint);
|
||||
std::wstring cycleCase(const std::wstring &);
|
||||
std::wstring transliterate(const std::wstring &);
|
||||
struct Snippet
|
||||
{
|
||||
std::wstring trigger, text;
|
||||
std::wstring id;
|
||||
};
|
||||
std::wstring snippetId(const Snippet &);
|
||||
std::wstring newSnippetId();
|
||||
struct Template
|
||||
{
|
||||
std::wstring text, suffix;
|
||||
int cursors = 0;
|
||||
int enters = 0;
|
||||
};
|
||||
Template parseTemplate(const std::wstring &);
|
||||
struct Hotkey
|
||||
@@ -48,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;
|
||||
@@ -70,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
|
||||
@@ -91,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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
#include "../vendor/json.hpp"
|
||||
#include "platform.h"
|
||||
#include <sstream>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
using DiagnosticJson = nlohmann::json;
|
||||
struct OperationLogRow
|
||||
{
|
||||
std::wstring time, process, command, source, target, outcome, details;
|
||||
bool problem = true;
|
||||
};
|
||||
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
|
||||
{
|
||||
std::vector<OperationLogRow> rows;
|
||||
std::wistringstream stream(contents);
|
||||
std::wstring line;
|
||||
OperationLogRow *legacy = nullptr;
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
if (!line.empty() && line.back() == L'\r')
|
||||
line.pop_back();
|
||||
if (line.empty())
|
||||
continue;
|
||||
auto marker = line.find(L" Operation {");
|
||||
if (marker != line.npos)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
|
||||
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
|
||||
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
|
||||
field("source"), field("target"), field("outcome")};
|
||||
row.problem = j.value("problem", true);
|
||||
row.details =
|
||||
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
||||
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
||||
field("original") + L"\r\n\r\nExpected 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: " +
|
||||
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
|
||||
L"\r\n\r\nContext and stages:\r\n" + field("context");
|
||||
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
|
||||
if (at == 0 || row.details[at - 1] != L'\r')
|
||||
row.details.insert(at++, 1, L'\r');
|
||||
rows.push_back(std::move(row));
|
||||
legacy = nullptr;
|
||||
continue;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
} // Preserve malformed and old records for diagnosis.
|
||||
}
|
||||
if (line.front() != L' ' || !legacy)
|
||||
{
|
||||
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
|
||||
legacy = &rows.back();
|
||||
}
|
||||
else
|
||||
legacy->details += L"\r\n" + line;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
|
||||
const std::wstring &expected)
|
||||
{
|
||||
if (actual == expected)
|
||||
return L"Paste verified";
|
||||
if (actual == original)
|
||||
return L"Original text remained";
|
||||
return L"Readback differs";
|
||||
}
|
||||
} // namespace ks
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace ks {
|
||||
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||
// visible preview to admit another visible preview: that creates a paint loop.
|
||||
template<class T>
|
||||
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||
const std::set<std::wstring> &visible, size_t limit=32)
|
||||
{
|
||||
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
||||
if(!visible.contains(it->first)) it=cache.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
}
|
||||
+37
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
||||
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
||||
return pixels;
|
||||
}
|
||||
Bytes encodePng(const Pixels &p)
|
||||
{
|
||||
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
|
||||
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
|
||||
throw std::runtime_error("Invalid PNG image dimensions");
|
||||
Com<IWICImagingFactory> factory;
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(factory.out())));
|
||||
Com<IStream> stream;
|
||||
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
|
||||
Com<IWICBitmapEncoder> encoder;
|
||||
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
|
||||
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
|
||||
Com<IWICBitmapFrameEncode> frame;
|
||||
check(encoder->CreateNewFrame(frame.out(), nullptr));
|
||||
check(frame->Initialize(nullptr));
|
||||
check(frame->SetSize(p.width, p.height));
|
||||
auto format = GUID_WICPixelFormat32bppBGRA;
|
||||
check(frame->SetPixelFormat(&format));
|
||||
if (format != GUID_WICPixelFormat32bppBGRA)
|
||||
throw std::runtime_error("Unsupported PNG pixel format");
|
||||
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
|
||||
const_cast<BYTE *>(p.bgra.data())));
|
||||
check(frame->Commit());
|
||||
check(encoder->Commit());
|
||||
STATSTG stat{};
|
||||
check(stream->Stat(&stat, STATFLAG_NONAME));
|
||||
if (stat.cbSize.QuadPart > UINT_MAX)
|
||||
throw std::runtime_error("PNG image is too large");
|
||||
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
|
||||
Bytes bytes((size_t)stat.cbSize.QuadPart);
|
||||
ULONG read{};
|
||||
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
|
||||
if (read != bytes.size())
|
||||
throw std::runtime_error("Cannot read encoded PNG image");
|
||||
return bytes;
|
||||
}
|
||||
Format imageDib(const Pixels &p)
|
||||
{
|
||||
BITMAPINFOHEADER h{};
|
||||
|
||||
@@ -9,6 +9,7 @@ struct Pixels
|
||||
};
|
||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||
Format imageDib(const Pixels &);
|
||||
Bytes encodePng(const Pixels &);
|
||||
std::wstring recognizeText(const Clip &);
|
||||
std::wstring recognizeText(const Clip &, const Settings &);
|
||||
std::filesystem::path tesseractExecutable(const Settings &);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
#include <uiautomation.h>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
// Read-only UI Automation: never select, move the caret, or touch the clipboard.
|
||||
class PasteProbe
|
||||
{
|
||||
IUIAutomation2 *automation{};
|
||||
IUIAutomationElement *element{};
|
||||
IUIAutomationTextPattern *pattern{};
|
||||
|
||||
public:
|
||||
std::wstring status = L"not-requested";
|
||||
PasteProbe(bool enabled, const std::wstring &original)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
status = L"accessibility-unavailable";
|
||||
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
|
||||
(void **)&automation)))
|
||||
return;
|
||||
automation->put_ConnectionTimeout(200);
|
||||
automation->put_TransactionTimeout(200);
|
||||
if (FAILED(automation->GetFocusedElement(&element)) || !element)
|
||||
return;
|
||||
BOOL password = TRUE;
|
||||
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
|
||||
{
|
||||
status = L"protected-or-inaccessible-field";
|
||||
return;
|
||||
}
|
||||
if (FAILED(element->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern,
|
||||
(void **)&pattern)) ||
|
||||
!pattern)
|
||||
return;
|
||||
auto selected = read(1, false);
|
||||
auto normalize = [](std::wstring value)
|
||||
{
|
||||
for (size_t at = 0; (at = value.find(L"\r\n", at)) != value.npos;)
|
||||
value.erase(at, 1);
|
||||
return value;
|
||||
};
|
||||
if (!selected || normalize(*selected) != normalize(original))
|
||||
{
|
||||
if (selected)
|
||||
status = L"initial-selection-mismatch";
|
||||
if (pattern)
|
||||
pattern->Release();
|
||||
pattern = nullptr;
|
||||
return;
|
||||
}
|
||||
status = L"ready";
|
||||
}
|
||||
PasteProbe(const PasteProbe &) = delete;
|
||||
~PasteProbe()
|
||||
{
|
||||
if (pattern)
|
||||
pattern->Release();
|
||||
if (element)
|
||||
element->Release();
|
||||
if (automation)
|
||||
automation->Release();
|
||||
}
|
||||
std::optional<std::wstring> read(int characters, bool allowCaret = true)
|
||||
{
|
||||
if (!pattern)
|
||||
return {};
|
||||
if (characters <= 0 || characters > 4096)
|
||||
{
|
||||
status = L"readback-size-limit";
|
||||
return {};
|
||||
}
|
||||
IUIAutomationElement *focused{};
|
||||
BOOL same = FALSE;
|
||||
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
|
||||
{
|
||||
automation->CompareElements(element, focused, &same);
|
||||
}
|
||||
if (!same)
|
||||
{
|
||||
if (focused)
|
||||
focused->Release();
|
||||
status = L"focused-control-changed";
|
||||
return {};
|
||||
}
|
||||
BOOL password = TRUE;
|
||||
if (FAILED(focused->get_CurrentIsPassword(&password)) || password)
|
||||
{
|
||||
focused->Release();
|
||||
status = L"protected-or-inaccessible-field";
|
||||
return {};
|
||||
}
|
||||
// Native Edit accessibility proxies can retain a text snapshot in the
|
||||
// pattern object. Obtain a fresh pattern after paste, not the pre-paste one.
|
||||
pattern->Release();
|
||||
pattern = nullptr;
|
||||
HRESULT refreshed =
|
||||
focused->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern, (void **)&pattern);
|
||||
focused->Release();
|
||||
if (FAILED(refreshed) || !pattern)
|
||||
{
|
||||
status = L"accessibility-unavailable";
|
||||
return {};
|
||||
}
|
||||
IUIAutomationTextRangeArray *ranges{};
|
||||
if (FAILED(pattern->GetSelection(&ranges)) || !ranges)
|
||||
{
|
||||
status = L"selection-unavailable";
|
||||
return {};
|
||||
}
|
||||
int count{};
|
||||
ranges->get_Length(&count);
|
||||
IUIAutomationTextRange *range{};
|
||||
if (count == 1)
|
||||
ranges->GetElement(0, &range);
|
||||
ranges->Release();
|
||||
if (!range)
|
||||
{
|
||||
status = L"selection-ambiguous";
|
||||
return {};
|
||||
}
|
||||
int difference{};
|
||||
HRESULT hr = range->CompareEndpoints(TextPatternRangeEndpoint_Start, range,
|
||||
TextPatternRangeEndpoint_End, &difference);
|
||||
if (SUCCEEDED(hr) && difference == 0)
|
||||
{
|
||||
if (!allowCaret)
|
||||
{
|
||||
range->Release();
|
||||
status = L"initial-selection-unavailable";
|
||||
return {};
|
||||
}
|
||||
int moved{};
|
||||
hr = range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start, TextUnit_Character, -characters,
|
||||
&moved);
|
||||
if (SUCCEEDED(hr) && moved != -characters)
|
||||
hr = E_FAIL;
|
||||
}
|
||||
BSTR value{};
|
||||
if (SUCCEEDED(hr))
|
||||
hr = range->GetText(8193, &value);
|
||||
range->Release();
|
||||
if (FAILED(hr))
|
||||
{
|
||||
SysFreeString(value);
|
||||
status = L"range-read-failed";
|
||||
return {};
|
||||
}
|
||||
std::wstring text(value ? value : L"", SysStringLen(value));
|
||||
SysFreeString(value);
|
||||
if (text.size() > 8192)
|
||||
{
|
||||
status = L"readback-size-limit";
|
||||
return {};
|
||||
}
|
||||
status = L"read-back-via-accessibility";
|
||||
return text;
|
||||
}
|
||||
};
|
||||
} // namespace ks
|
||||
+33
-6
@@ -1,6 +1,8 @@
|
||||
#include "platform.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <imm.h>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
@@ -394,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;
|
||||
@@ -431,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();
|
||||
@@ -571,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)
|
||||
@@ -624,6 +634,22 @@ void diagnosticEvent(const std::wstring &message, bool problem)
|
||||
if (!incident.empty())
|
||||
log(incident);
|
||||
}
|
||||
void diagnosticOperation(const std::string &json)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
if (!loggingEnabled) return;
|
||||
auto record = nlohmann::json::parse(json);
|
||||
if (!record.value("problem", true))
|
||||
{
|
||||
// Keep successful operations only in the bounded, short-lived context.
|
||||
// They are persisted with a subsequent failure, never as standalone rows.
|
||||
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
|
||||
nlohmann::json::error_handler_t::replace)), false, GetTickCount64());
|
||||
return;
|
||||
}
|
||||
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
|
||||
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
|
||||
}
|
||||
void log(const std::wstring &message)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
@@ -645,8 +671,9 @@ void log(const std::wstring &message)
|
||||
std::ofstream f(path, std::ios::app);
|
||||
SYSTEMTIME t;
|
||||
GetLocalTime(&t);
|
||||
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
|
||||
<< t.wSecond << ' ' << utf8(message) << '\n';
|
||||
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
|
||||
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
|
||||
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline std::wstring localBuildTitle(const std::wstring &title)
|
||||
{
|
||||
static const bool development=[] {
|
||||
wchar_t path[32768]{};
|
||||
if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false;
|
||||
std::error_code error;
|
||||
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
|
||||
}();
|
||||
return development?title+L" [LOCAL DEV]":title;
|
||||
}
|
||||
using Bytes = std::vector<unsigned char>;
|
||||
struct Format
|
||||
{
|
||||
@@ -66,6 +76,7 @@ class DiagnosticBuffer
|
||||
};
|
||||
void setLoggingEnabled(bool enabled);
|
||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||
void diagnosticOperation(const std::string &json);
|
||||
std::wstring readDiagnosticLog(bool previous);
|
||||
void clearDiagnosticLog(bool previous);
|
||||
} // namespace ks
|
||||
|
||||
+424
-36
@@ -1,4 +1,5 @@
|
||||
#include "screenshot.h"
|
||||
#include "stroke_improvement.h"
|
||||
#include "ocr.h"
|
||||
#include "../resources/resource.h"
|
||||
#include <algorithm>
|
||||
@@ -28,8 +29,8 @@ bool confirmScreenshotClose(HWND owner)
|
||||
dialog.hMainIcon=icon;
|
||||
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
||||
dialog.pszWindowTitle=L"KSwitcher";
|
||||
dialog.pszMainInstruction=L"Close without copying?";
|
||||
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
|
||||
dialog.pszMainInstruction=L"Close without saving or copying?";
|
||||
dialog.pszContent=L"Your latest screenshot changes have not been saved to a file or copied to the clipboard.";
|
||||
dialog.nDefaultButton=IDNO;
|
||||
dialog.lpCallbackData=(LONG_PTR)owner;
|
||||
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
||||
@@ -53,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
|
||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||
return SUCCEEDED(result) && button==IDYES;
|
||||
}
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,21,19,26,20,12,18,13};
|
||||
struct Surface
|
||||
{
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
@@ -123,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
|
||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||
return true;
|
||||
}
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,19};
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,19};
|
||||
HCURSOR makeToolCursor(int tool,int size)
|
||||
{
|
||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
||||
@@ -148,6 +149,8 @@ HCURSOR makeToolCursor(int tool,int size)
|
||||
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
||||
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
||||
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
||||
case 36: { POINT p[]{{x(23),y(17)},{x(31),y(30)},{x(16),y(30)},{x(23),y(17)}}; Polyline(dc,p,4); break; }
|
||||
case 37: { POINT p[]{{x(20),y(18)},{x(27),y(18)},{x(31),y(30)},{x(16),y(30)},{x(20),y(18)}}; Polyline(dc,p,5); break; }
|
||||
case 19:
|
||||
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
|
||||
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
|
||||
@@ -178,8 +181,19 @@ struct Capture
|
||||
COLORREF currentColor=RGB(225,42,50);
|
||||
COLORREF customColors[16]{};
|
||||
Mark stroke{};
|
||||
bool strokeConstrained{};
|
||||
bool strokeUsedConstraint{};
|
||||
size_t strokeAnchor{};
|
||||
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
||||
bool strokeAxisSettled{};
|
||||
std::vector<POINT> strokeMotion;
|
||||
POINT start{}, end{};
|
||||
RECT selection{}, view{};
|
||||
RECT canvas{};
|
||||
double zoom=1,panX=0,panY=0;
|
||||
bool panning=false;
|
||||
POINT panOrigin{};
|
||||
int zoomWheelDelta=0;
|
||||
std::vector<Mark> marks;
|
||||
ScreenshotHistory history;
|
||||
HWND editor{}, inlineText{};
|
||||
@@ -203,6 +217,7 @@ struct Capture
|
||||
int hoveredTool=0;
|
||||
MarkPart hoveredPart=MarkPart::None;
|
||||
std::wstring statusText;
|
||||
std::filesystem::path savedPath;
|
||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
||||
~Capture() {
|
||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||
@@ -234,11 +249,13 @@ void updateStatus(Capture &c)
|
||||
if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot";
|
||||
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
|
||||
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
|
||||
if(c.hoveredTool==28) text=L"Save (Ctrl+S) | Save As... (Ctrl+Shift+S)";
|
||||
}
|
||||
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
||||
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
||||
else if(part>=MarkPart::Vertex0 && part<=MarkPart::Vertex2) text=L"Drag triangle vertex | Other vertices stay fixed";
|
||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows";
|
||||
else if(c.selected>=0) {
|
||||
const auto &m=c.marks[c.selected];
|
||||
@@ -250,9 +267,11 @@ void updateStatus(Capture &c)
|
||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
|
||||
else if(c.freehand) text=L"Freehand (F): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | Ctrl = draw over objects";
|
||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
||||
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
||||
L"% (Ctrl +/- / wheel; Ctrl+0: fit; middle drag: pan) | "+text;
|
||||
if(text!=c.statusText) {
|
||||
c.statusText=std::move(text);
|
||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
@@ -292,6 +311,12 @@ void positionInlineText(Capture &c)
|
||||
int width=std::min(std::max(120,MulDiv(bounds.right-bounds.left,viewWidth,c.image->width)+24),std::max(32,int(client.right)-x));
|
||||
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
||||
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT editorBounds{x-2,y-2,x-2+width,y-2+height},visible{};
|
||||
IntersectRect(&visible,&editorBounds,&c.canvas);
|
||||
auto clip=IsRectEmpty(&visible)?CreateRectRgn(0,0,0,0):
|
||||
CreateRectRgn(visible.left-editorBounds.left,visible.top-editorBounds.top,
|
||||
visible.right-editorBounds.left,visible.bottom-editorBounds.top);
|
||||
if(!SetWindowRgn(c.inlineText,clip,TRUE)) DeleteObject(clip);
|
||||
}
|
||||
Mark textStyleDefaults(const Mark &source)
|
||||
{
|
||||
@@ -332,6 +357,14 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
||||
}
|
||||
LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(msg==WM_KEYDOWN && (GetKeyState(VK_CONTROL)&0x8000) &&
|
||||
(wp==VK_OEM_PLUS || wp==VK_ADD || wp==VK_OEM_MINUS || wp==VK_SUBTRACT || wp=='0' || wp==VK_NUMPAD0)) {
|
||||
SendMessageW(GetParent(w),msg,wp,lp); return 0;
|
||||
}
|
||||
if(msg==WM_KEYDOWN && wp=='S' && (GetKeyState(VK_CONTROL)&0x8000)) {
|
||||
PostMessageW(GetParent(w),WM_COMMAND,(GetKeyState(VK_SHIFT)&0x8000)?29:28,0); return 0;
|
||||
}
|
||||
if(msg==WM_CHAR && wp==19) return 0; // Ctrl+S is handled by the editor.
|
||||
if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) {
|
||||
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
|
||||
}
|
||||
@@ -361,9 +394,47 @@ void beginMarkText(HWND w,Capture &c,int index)
|
||||
beginInlineText(w,c,label,index); c.editingLabel=true;
|
||||
}
|
||||
}
|
||||
POINT rotateShapePoint(const Mark &m,POINT p,double direction=1)
|
||||
{
|
||||
if(m.shape==ScreenshotShape::None || m.rotation==0) return p;
|
||||
double cx=(m.from.x+m.to.x)/2.0,cy=(m.from.y+m.to.y)/2.0;
|
||||
double a=direction*m.rotation*3.141592653589793/180,cs=std::cos(a),sn=std::sin(a);
|
||||
return {LONG(std::lround(cx+(p.x-cx)*cs-(p.y-cy)*sn)),
|
||||
LONG(std::lround(cy+(p.x-cx)*sn+(p.y-cy)*cs))};
|
||||
}
|
||||
std::vector<POINT> shapeOutline(const Mark &m)
|
||||
{
|
||||
auto points=m.points;
|
||||
if(points.empty()) {
|
||||
auto r=region(m.from,m.to);
|
||||
if(m.shape==ScreenshotShape::Rectangle)
|
||||
points={{r.left,r.top},{r.right,r.top},{r.right,r.bottom},{r.left,r.bottom},{r.left,r.top}};
|
||||
else if(m.shape==ScreenshotShape::Triangle)
|
||||
points={{(r.left+r.right)/2,r.top},{r.right,r.bottom},{r.left,r.bottom},{(r.left+r.right)/2,r.top}};
|
||||
else if(m.shape==ScreenshotShape::Trapezoid)
|
||||
points={{r.left+(r.right-r.left)/4,r.top},{r.right-(r.right-r.left)/4,r.top},
|
||||
{r.right,r.bottom},{r.left,r.bottom},{r.left+(r.right-r.left)/4,r.top}};
|
||||
else {
|
||||
for(int i=0;i<180;++i) {
|
||||
double a=i*2*3.141592653589793/180;
|
||||
points.push_back({LONG(std::lround((r.left+r.right)/2.0+(r.right-r.left)/2.0*std::cos(a))),
|
||||
LONG(std::lround((r.top+r.bottom)/2.0+(r.bottom-r.top)/2.0*std::sin(a)))});
|
||||
}
|
||||
points.push_back(points.front());
|
||||
}
|
||||
}
|
||||
for(auto &p:points) p=rotateShapePoint(m,p);
|
||||
return points;
|
||||
}
|
||||
RECT markBounds(const Mark &m)
|
||||
{
|
||||
if(m.isText()) return screenshotTextBounds(m);
|
||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||
auto points=shapeOutline(m); RECT r{points[0].x,points[0].y,points[0].x,points[0].y};
|
||||
for(auto p:points) { r.left=std::min(r.left,p.x); r.right=std::max(r.right,p.x);
|
||||
r.top=std::min(r.top,p.y); r.bottom=std::max(r.bottom,p.y); }
|
||||
return r;
|
||||
}
|
||||
RECT r=region(m.from,m.to);
|
||||
for(auto p:m.points) {
|
||||
r.left=std::min(r.left,p.x); r.top=std::min(r.top,p.y);
|
||||
@@ -375,7 +446,7 @@ std::pair<POINT,POINT> thicknessSegment(const Mark &m)
|
||||
{
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto r=region(m.from,m.to);
|
||||
return {{r.right,r.top},{r.left,r.top}};
|
||||
return {rotateShapePoint(m,{r.right,r.top}),rotateShapePoint(m,{r.left,r.top})};
|
||||
}
|
||||
if(m.freehand && m.points.size()>1) {
|
||||
size_t index=(m.points.size()-1)/2;
|
||||
@@ -479,7 +550,10 @@ void drawMarks(HDC dc, const std::vector<Mark> &marks, const Mark *preview = nul
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
||||
auto r=region(m.from,m.to);
|
||||
if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid) {
|
||||
auto points=shapeOutline(m); Polyline(dc,points.data(),int(points.size()));
|
||||
}
|
||||
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
else Ellipse(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||
SelectObject(dc,hollow);
|
||||
SelectObject(dc,previousPen); DeleteObject(arrowPen);
|
||||
@@ -594,7 +668,7 @@ void layout(HWND w, Capture &c)
|
||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||
r.bottom-=c.statusHeight;
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,488,524,572};
|
||||
static_assert(std::size(positions)==std::size(ToolIds));
|
||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||
{
|
||||
@@ -602,6 +676,9 @@ void layout(HWND w, Capture &c)
|
||||
MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96),
|
||||
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||
}
|
||||
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||
MoveWindow(GetDlgItem(w,28),MulDiv(612,dpi,96),MulDiv(6,dpi,96),
|
||||
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
||||
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||
@@ -611,13 +688,35 @@ void layout(HWND w, Capture &c)
|
||||
MoveWindow(GetDlgItem(w,34),MulDiv(480,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
||||
MoveWindow(GetDlgItem(w,35),MulDiv(516,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
||||
syncLineOptions(w,c);
|
||||
double ratio = std::min({1.0, double(std::max(1L,r.right-16))/c.image->width,
|
||||
c.canvas={0,c.panelHeight,r.right,std::max(LONG(c.panelHeight+1),r.bottom)};
|
||||
double ratio = c.zoom*std::min({1.0, double(std::max(1L,r.right-16))/c.image->width,
|
||||
double(std::max(1L,r.bottom-c.panelHeight-16))/c.image->height});
|
||||
int width = int(c.image->width*ratio), height = int(c.image->height*ratio);
|
||||
c.view = {(r.right-width)/2, c.panelHeight+(r.bottom-c.panelHeight-height)/2,
|
||||
(r.right+width)/2, c.panelHeight+(r.bottom-c.panelHeight+height)/2};
|
||||
int width = std::max(1,int(std::lround(c.image->width*ratio))), height = std::max(1,int(std::lround(c.image->height*ratio)));
|
||||
double maxX=std::max(0.0,(width-double(c.canvas.right-c.canvas.left))/2);
|
||||
double maxY=std::max(0.0,(height-double(c.canvas.bottom-c.canvas.top))/2);
|
||||
c.panX=std::clamp(c.panX,-maxX,maxX); c.panY=std::clamp(c.panY,-maxY,maxY);
|
||||
LONG x=LONG(std::lround((r.right-width)/2.0+c.panX));
|
||||
LONG y=LONG(std::lround(c.panelHeight+(r.bottom-c.panelHeight-height)/2.0+c.panY));
|
||||
c.view = {x,y,x+width,y+height};
|
||||
InvalidateRect(w, nullptr, FALSE);
|
||||
}
|
||||
void zoomCanvas(HWND w,Capture &c,double factor,POINT anchor,bool reset=false)
|
||||
{
|
||||
if(!c.editing || c.dragging || c.panning) return;
|
||||
double u=double(anchor.x-c.view.left)/std::max(1L,c.view.right-c.view.left);
|
||||
double v=double(anchor.y-c.view.top)/std::max(1L,c.view.bottom-c.view.top);
|
||||
double next=reset?1.0:std::clamp(c.zoom*factor,.1,16.0);
|
||||
if(next==c.zoom && !reset) return;
|
||||
c.zoom=next;
|
||||
if(reset) c.panX=c.panY=0;
|
||||
layout(w,c);
|
||||
if(!reset) {
|
||||
c.panX+=anchor.x-(c.view.left+u*(c.view.right-c.view.left));
|
||||
c.panY+=anchor.y-(c.view.top+v*(c.view.bottom-c.view.top));
|
||||
layout(w,c);
|
||||
}
|
||||
positionInlineText(c); updateStatus(c); InvalidateRect(w,nullptr,FALSE);
|
||||
}
|
||||
POINT imagePoint(Capture &c, POINT p, bool clamp = true)
|
||||
{
|
||||
POINT result{MulDiv(p.x-c.view.left,c.image->width,std::max(1L,c.view.right-c.view.left)),
|
||||
@@ -645,6 +744,10 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
||||
if(c.selected>=0)
|
||||
{
|
||||
const auto &m=c.marks[c.selected];
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto grip=screenshotShapeRotationHandle(m,double(c.unit)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
if(std::hypot(double(p.x-grip.x),double(p.y-grip.y))<=tolerance) return {c.selected,MarkPart::Rotation};
|
||||
}
|
||||
if(m.isText() || (m.isLine() && !m.label.empty())) {
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
auto grip=screenshotTextRotationHandle(text,double(c.unit*0.6)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
@@ -668,7 +771,7 @@ HCURSOR toolCursor(const Capture &c)
|
||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
||||
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||
int tool=c.highlight?19:c.straight?22:c.shape==ScreenshotShape::Trapezoid?37:c.shape==ScreenshotShape::Triangle?36:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||
return LoadCursorW(nullptr,IDC_CROSS);
|
||||
@@ -679,6 +782,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
LPCWSTR cursor=nullptr;
|
||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
||||
else if(hit.second>=MarkPart::Vertex0 && hit.second<=MarkPart::Vertex2) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
||||
const auto &mark=c.marks[hit.first];
|
||||
@@ -694,7 +798,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
void captureCursor(HWND w, Capture &c)
|
||||
{
|
||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||
bool inside=PtInRect(&c.view,p)!=FALSE;
|
||||
bool inside=PtInRect(&c.view,p) && PtInRect(&c.canvas,p);
|
||||
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
|
||||
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||
SetCursor(actionCursor(c,hit,inside || c.dragging));
|
||||
@@ -750,7 +854,18 @@ void drawHandles(HDC dc, const Capture &c)
|
||||
handle({m.from.x+m.radius,m.from.y});
|
||||
}
|
||||
else {
|
||||
handle(m.from); handle(m.to);
|
||||
if(m.shape==ScreenshotShape::Triangle) {
|
||||
auto vertices=shapeOutline(m);
|
||||
for(size_t i=0;i<3;++i) handle(vertices[i]);
|
||||
} else { handle(rotateShapePoint(m,m.from)); handle(rotateShapePoint(m,m.to)); }
|
||||
if(m.shape!=ScreenshotShape::None) {
|
||||
auto r=region(m.from,m.to);
|
||||
auto top=point(rotateShapePoint(m,{(r.left+r.right)/2,r.top}));
|
||||
auto grip=screenshotShapeRotationHandle(m,double(c.unit)*c.image->width/std::max(1L,c.view.right-c.view.left));
|
||||
auto end=point(grip); MoveToEx(dc,top.x,top.y,nullptr); LineTo(dc,end.x,end.y);
|
||||
int radius=std::max(4,c.unit/8);
|
||||
Ellipse(dc,end.x-radius,end.y-radius,end.x+radius+1,end.y+radius+1);
|
||||
}
|
||||
auto [from,to]=thicknessSegment(m);
|
||||
auto mid=point({(from.x+to.x)/2,(from.y+to.y)/2});
|
||||
auto grip=thicknessHandle(c,m),end=point(grip);
|
||||
@@ -766,7 +881,8 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RECT r=item.rcItem;
|
||||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
||||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool)||
|
||||
(item.CtlID==36 && c.shape==ScreenshotShape::Triangle)||(item.CtlID==37 && c.shape==ScreenshotShape::Trapezoid);
|
||||
bool disabled=item.itemState&ODS_DISABLED;
|
||||
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
||||
bool hover=PtInRect(&r,mouse) && !disabled;
|
||||
@@ -780,7 +896,16 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
auto icon=(item.CtlID==36 || item.CtlID==37)?nullptr:item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
if(item.CtlID==36 || item.CtlID==37) {
|
||||
int cx=(r.left+r.right)/2,cy=(r.top+r.bottom)/2,s=c.iconSize/2;
|
||||
auto trianglePen=CreatePen(PS_SOLID,std::max(1,MulDiv(2,GetDpiForWindow(item.hwndItem),96)),
|
||||
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
||||
auto previous=SelectObject(dc,trianglePen);
|
||||
if(item.CtlID==36) { POINT vertices[]{{cx,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx,cy-s}}; Polyline(dc,vertices,4); }
|
||||
else { POINT vertices[]{{cx-s/2,cy-s},{cx+s/2,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx-s/2,cy-s}}; Polyline(dc,vertices,5); }
|
||||
SelectObject(dc,previous); DeleteObject(trianglePen);
|
||||
}
|
||||
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
||||
if(item.CtlID==20) {
|
||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
||||
@@ -816,6 +941,64 @@ void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard
|
||||
publish(c.app.window, clip);
|
||||
c.copied=true;
|
||||
}
|
||||
void saveToFile(HWND w, Capture &c, const std::filesystem::path &path)
|
||||
{
|
||||
finishInlineText(w,c,true);
|
||||
auto pixels=decodeImage(screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks));
|
||||
// GDI-rendered screenshot pixels are opaque; GDI does not maintain alpha.
|
||||
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||
auto bytes=encodePng(pixels);
|
||||
GUID id{};
|
||||
if(FAILED(CoCreateGuid(&id))) throw std::runtime_error("Cannot prepare screenshot output file");
|
||||
wchar_t unique[40]{}; StringFromGUID2(id,unique,(int)std::size(unique));
|
||||
auto temporary=path.parent_path()/(L".kswitcher-"+std::wstring(unique)+L".png");
|
||||
try {
|
||||
atomicWrite(temporary,bytes);
|
||||
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("Cannot save screenshot to the selected file");
|
||||
} catch(...) {
|
||||
std::error_code ignored; std::filesystem::remove(temporary,ignored); throw;
|
||||
}
|
||||
c.savedPath=path;
|
||||
c.copied=true; // Current edits have been exported, either to file or clipboard.
|
||||
}
|
||||
void save(HWND w, Capture &c, bool saveAs=false,
|
||||
BOOL (WINAPI *chooseFile)(LPOPENFILENAMEW)=GetSaveFileNameW)
|
||||
{
|
||||
if(!saveAs && !c.savedPath.empty()) {
|
||||
saveToFile(w,c,c.savedPath);
|
||||
return;
|
||||
}
|
||||
wchar_t filename[32768]{};
|
||||
auto proposed=c.savedPath.wstring();
|
||||
if(proposed.empty()) {
|
||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||
wchar_t name[100]{};
|
||||
swprintf_s(name,L"Screenshot-%04u%02u%02u-%02u%02u%02u.png",
|
||||
time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond);
|
||||
proposed=name;
|
||||
}
|
||||
if(proposed.size()>=std::size(filename)) throw std::runtime_error("Screenshot file path is too long");
|
||||
std::copy(proposed.begin(),proposed.end(),filename);
|
||||
OPENFILENAMEW dialog{sizeof(dialog)};
|
||||
dialog.hwndOwner=w;
|
||||
dialog.lpstrTitle=L"Save screenshot";
|
||||
dialog.lpstrFilter=L"PNG image (*.png)\0*.png\0\0";
|
||||
dialog.nFilterIndex=1;
|
||||
dialog.lpstrFile=filename;
|
||||
dialog.nMaxFile=(DWORD)std::size(filename);
|
||||
dialog.lpstrDefExt=L"png";
|
||||
dialog.Flags=OFN_OVERWRITEPROMPT|OFN_PATHMUSTEXIST|OFN_NOCHANGEDIR|OFN_EXPLORER;
|
||||
if(!chooseFile(&dialog)) {
|
||||
if(CommDlgExtendedError()) throw std::runtime_error("Cannot open the screenshot save dialog");
|
||||
return;
|
||||
}
|
||||
if(lower(std::filesystem::path(filename).extension().wstring())!=L".png")
|
||||
throw std::runtime_error("Choose a filename ending in .png for the screenshot");
|
||||
saveToFile(w,c,filename);
|
||||
c.statusText=L"Saved: "+c.savedPath.wstring();
|
||||
SendDlgItemMessageW(w,50,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
}
|
||||
void edit(HWND w, Capture &c)
|
||||
{
|
||||
auto r = region(c.start,c.end);
|
||||
@@ -836,14 +1019,17 @@ void edit(HWND w, Capture &c)
|
||||
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
|
||||
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
|
||||
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
|
||||
SetWindowTextW(w,title.c_str());
|
||||
SetWindowTextW(w,localBuildTitle(title).c_str());
|
||||
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
|
||||
InitCommonControlsEx(&statusControls);
|
||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||
static_assert(std::size(labels)==std::size(ToolIds));
|
||||
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||
0,0,0,0,w,(HMENU)28,c.app.instance,nullptr);
|
||||
SetWindowSubclass(saveButton,toolHover,1,0);
|
||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
|
||||
for (size_t i=0;i<std::size(ToolIds);++i)
|
||||
@@ -933,7 +1119,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
break;
|
||||
case WM_DRAWITEM:
|
||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35 || wp==36 || wp==37) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||
break;
|
||||
case WM_CTLCOLORSTATIC:
|
||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||
@@ -1018,19 +1204,22 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
||||
}
|
||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||
if (LOWORD(wp)==28 || LOWORD(wp)==29) {
|
||||
save(w,*c,LOWORD(wp)==29); SetFocus(w); return 0;
|
||||
}
|
||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||
{
|
||||
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||
c->syncHistory();
|
||||
}
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
{
|
||||
c->pointer=LOWORD(wp)==27;
|
||||
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->straight=LOWORD(wp)==22;
|
||||
c->textTool=LOWORD(wp)==26;
|
||||
c->shape=LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||
c->shape=LOWORD(wp)==37?ScreenshotShape::Trapezoid:LOWORD(wp)==36?ScreenshotShape::Triangle:LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||
c->selected=-1; c->selectedLabel=false;
|
||||
c->highlight=LOWORD(wp)==19;
|
||||
c->freehand=LOWORD(wp)==21;
|
||||
@@ -1041,16 +1230,32 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
||||
}
|
||||
|
||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
case WM_KEYDOWN:
|
||||
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
|
||||
c->strokeConstrained=true;
|
||||
c->strokeUsedConstraint=true;
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
c->strokeAxis=0;
|
||||
c->strokeAxisSettled=false;
|
||||
c->strokeMotion={c->stroke.points.back()};
|
||||
}
|
||||
if (wp==VK_ESCAPE) {
|
||||
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||
return 0;
|
||||
}
|
||||
if (c->editing && !c->dragging)
|
||||
{
|
||||
if((GetKeyState(VK_CONTROL)&0x8000) &&
|
||||
(wp==VK_OEM_PLUS || wp==VK_ADD || wp==VK_OEM_MINUS || wp==VK_SUBTRACT || wp=='0' || wp==VK_NUMPAD0)) {
|
||||
POINT center{(c->canvas.left+c->canvas.right)/2,(c->canvas.top+c->canvas.bottom)/2};
|
||||
zoomCanvas(w,*c,wp==VK_OEM_MINUS || wp==VK_SUBTRACT?1/1.25:1.25,center,wp=='0' || wp==VK_NUMPAD0);
|
||||
return 0;
|
||||
}
|
||||
if(wp>=VK_LEFT && wp<=VK_DOWN && c->selected>=0 && !c->inlineText) {
|
||||
POINT delta{wp==VK_LEFT?-1L:wp==VK_RIGHT?1L:0L,wp==VK_UP?-1L:wp==VK_DOWN?1L:0L};
|
||||
const auto &original=c->marks[c->selected];
|
||||
@@ -1067,6 +1272,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
beginMarkText(w,*c,c->selected); return 0;
|
||||
}
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='S') {
|
||||
save(w,*c,(GetKeyState(VK_SHIFT)&0x8000)!=0); SetFocus(w); return 0;
|
||||
}
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
|
||||
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
|
||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
|
||||
@@ -1082,6 +1290,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||
if(wp=='O') SendMessageW(w,WM_COMMAND,24,0);
|
||||
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
||||
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
||||
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
||||
}
|
||||
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
||||
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
||||
@@ -1100,10 +1310,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_KEYUP:
|
||||
if(wp==VK_SHIFT) c->strokeConstrained=false;
|
||||
return 0;
|
||||
case WM_LBUTTONDBLCLK:
|
||||
if(c->editing && !c->dragging) {
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(PtInRect(&c->view,p)) {
|
||||
if(PtInRect(&c->view,p) && PtInRect(&c->canvas,p)) {
|
||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||
if(index>=0 && (c->marks[index].isText() || c->marks[index].isLine())) {
|
||||
c->selected=index; beginMarkText(w,*c,index);
|
||||
@@ -1113,8 +1326,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
case WM_LBUTTONDOWN:
|
||||
{
|
||||
if(c->panning) return 0;
|
||||
finishInlineText(w,*c,true);
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(c->editing && !PtInRect(&c->canvas,p)) return 0;
|
||||
auto hitPart=c->editing?markAt(*c,imagePoint(*c,p,false)).second:MarkPart::None;
|
||||
bool thickness=hitPart==MarkPart::Thickness || hitPart==MarkPart::Rotation;
|
||||
if (c->editing && !PtInRect(&c->view,p) && !thickness)
|
||||
@@ -1156,6 +1371,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
|
||||
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||
c->stroke.points.push_back(p);
|
||||
c->strokeConstrained=false;
|
||||
c->strokeUsedConstraint=(wp&MK_SHIFT)!=0;
|
||||
}
|
||||
c->dragging=true; SetCapture(w);
|
||||
}
|
||||
@@ -1163,8 +1380,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case WM_MOUSEWHEEL:
|
||||
{
|
||||
if(!c->editing || c->dragging) return 0;
|
||||
if(!c->editing || c->dragging || c->panning) return 0;
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)}; ScreenToClient(w,&p);
|
||||
if(!PtInRect(&c->canvas,p)) return 0;
|
||||
if(GET_KEYSTATE_WPARAM(wp)&MK_CONTROL) {
|
||||
c->wheelDelta=0; c->wheelTarget=-1;
|
||||
c->zoomWheelDelta+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||
int steps=c->zoomWheelDelta/WHEEL_DELTA; c->zoomWheelDelta%=WHEEL_DELTA;
|
||||
if(steps) zoomCanvas(w,*c,std::pow(1.25,steps),p);
|
||||
return 0;
|
||||
}
|
||||
c->zoomWheelDelta=0;
|
||||
if(!PtInRect(&c->view,p)) return 0;
|
||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||
if(index<0) { c->wheelDelta=0; c->wheelTarget=-1; return 0; }
|
||||
@@ -1184,8 +1410,27 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MBUTTONDOWN:
|
||||
{
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(c->editing && !c->dragging && PtInRect(&c->canvas,p)) {
|
||||
c->panning=true; c->panOrigin=p; SetCapture(w); SetCursor(LoadCursorW(nullptr,IDC_SIZEALL));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MBUTTONUP:
|
||||
if(c->panning) { c->panning=false; ReleaseCapture(); captureCursor(w,*c); }
|
||||
return 0;
|
||||
case WM_MOUSEMOVE:
|
||||
case WM_LBUTTONUP:
|
||||
if(c->panning) {
|
||||
if(msg==WM_MOUSEMOVE) {
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
c->panX+=p.x-c->panOrigin.x; c->panY+=p.y-c->panOrigin.y; c->panOrigin=p;
|
||||
layout(w,*c); positionInlineText(*c); SetCursor(LoadCursorW(nullptr,IDC_SIZEALL));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (c->dragging)
|
||||
{
|
||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
@@ -1193,6 +1438,64 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
|
||||
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
||||
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
||||
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
|
||||
c->strokeUsedConstraint=c->strokeUsedConstraint || constrained;
|
||||
if(constrained) {
|
||||
if(!c->strokeConstrained) {
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
c->strokeAxis=0;
|
||||
c->strokeAxisSettled=false;
|
||||
c->strokeMotion={c->stroke.points.back()};
|
||||
}
|
||||
auto anchor=c->stroke.points[c->strokeAnchor];
|
||||
// Measure the actual mouse trajectory, not its snapped projection.
|
||||
auto raw=c->end;
|
||||
if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y)
|
||||
c->strokeMotion.push_back(raw);
|
||||
while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 ||
|
||||
std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16))
|
||||
c->strokeMotion.erase(c->strokeMotion.begin());
|
||||
auto chooseAxis=[](POINT from,POINT to) {
|
||||
LONG dx=to.x-from.x,dy=to.y-from.y;
|
||||
constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees)
|
||||
if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1;
|
||||
if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2;
|
||||
return (dx<0)==(dy<0)?3:4;
|
||||
};
|
||||
auto distance=[](int axis,POINT from,POINT to) {
|
||||
double dx=to.x-from.x,dy=to.y-from.y;
|
||||
return axis==1?std::abs(dy):axis==2?std::abs(dx):
|
||||
std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0);
|
||||
};
|
||||
if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) {
|
||||
c->strokeAxis=chooseAxis(anchor,c->end);
|
||||
// A one-pixel first movement must not lock a diagonal gesture to an axis.
|
||||
c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8;
|
||||
}
|
||||
else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) {
|
||||
// Keep the completed segment and turn at its endpoint.
|
||||
auto corner=c->stroke.points.back();
|
||||
int nextAxis=chooseAxis(c->strokeMotion.front(),raw);
|
||||
if(nextAxis!=c->strokeAxis) {
|
||||
c->strokeAnchor=c->stroke.points.size()-1;
|
||||
anchor=corner;
|
||||
c->strokeAxis=nextAxis;
|
||||
}
|
||||
}
|
||||
if(c->strokeAxis==1) c->end.y=anchor.y;
|
||||
else if(c->strokeAxis==2) c->end.x=anchor.x;
|
||||
else if(c->strokeAxis>=3) {
|
||||
int sign=c->strokeAxis==3?1:-1;
|
||||
LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0);
|
||||
LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1);
|
||||
LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y;
|
||||
delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY));
|
||||
c->end={anchor.x+delta,anchor.y+sign*delta};
|
||||
}
|
||||
// Only the current segment is a preview; earlier corners stay fixed.
|
||||
c->stroke.points.resize(c->strokeAnchor+1);
|
||||
}
|
||||
c->strokeConstrained=constrained;
|
||||
auto last=c->stroke.points.back();
|
||||
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
||||
c->stroke.to=c->end;
|
||||
@@ -1230,6 +1533,25 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
}
|
||||
if(c->freehand && !c->strokeUsedConstraint &&
|
||||
!c->marks.back().fromNumber && !c->marks.back().toNumber &&
|
||||
!c->marks.back().fromLink.target && !c->marks.back().toLink.target) {
|
||||
ScreenshotShape recognized{};
|
||||
bool straightLine=false;
|
||||
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized,&straightLine);
|
||||
if(!improved.empty()) {
|
||||
c->remember(c->marks);
|
||||
auto &mark=c->marks.back();
|
||||
mark.points=std::move(improved);
|
||||
mark.from=mark.points.front(); mark.to=mark.points.back();
|
||||
if(straightLine) { mark.points.clear(); mark.freehand=false; }
|
||||
if(recognized!=ScreenshotShape::None) {
|
||||
mark.shape=recognized; mark.freehand=false; mark.ends=LineEnds::None;
|
||||
auto bounds=markBounds(mark);
|
||||
mark.from={bounds.left,bounds.top}; mark.to={bounds.right,bounds.bottom};
|
||||
}
|
||||
}
|
||||
}
|
||||
c->selected=int(c->marks.size())-1; c->selectedLabel=false;
|
||||
}
|
||||
else if (std::hypot(double(c->end.x-c->start.x),double(c->end.y-c->start.y))>=4)
|
||||
@@ -1240,6 +1562,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
c->marks.back().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
||||
c->marks.back().shape=c->shape;
|
||||
auto &mark=c->marks.back();
|
||||
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid) mark.points=shapeOutline(mark);
|
||||
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
@@ -1252,6 +1575,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
captureCursor(w,*c);
|
||||
return 0;
|
||||
case WM_CAPTURECHANGED:
|
||||
c->panning=false;
|
||||
if(c->dragging && c->part!=MarkPart::None)
|
||||
{
|
||||
c->marks=std::move(c->beforeDrag); c->part=MarkPart::None;
|
||||
@@ -1261,7 +1585,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (c->editing)
|
||||
{
|
||||
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
||||
limits->ptMinTrackSize = {MulDiv(650,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||
limits->ptMinTrackSize = {MulDiv(730,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -1311,7 +1635,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
auto previous=SelectObject(buffer,divider);
|
||||
MoveToEx(buffer,0,c->panelHeight-1,nullptr); LineTo(buffer,client.right,c->panelHeight-1);
|
||||
int dpi=GetDpiForWindow(w);
|
||||
for(int x:{368,452}) {
|
||||
for(int x:{440,524}) {
|
||||
MoveToEx(buffer,MulDiv(x,dpi,96),MulDiv(13,dpi,96),nullptr);
|
||||
LineTo(buffer,MulDiv(x,dpi,96),MulDiv(31,dpi,96));
|
||||
}
|
||||
@@ -1329,9 +1653,12 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
drawMarks(rendered.dc,visibleMarks,c->dragging && c->part==MarkPart::None?((c->highlight || c->freehand)?&c->stroke:&preview):nullptr);
|
||||
SetStretchBltMode(buffer,HALFTONE); SetBrushOrgEx(buffer,0,0,nullptr);
|
||||
int savedCanvas=SaveDC(buffer);
|
||||
IntersectClipRect(buffer,c->canvas.left,c->canvas.top,c->canvas.right,c->canvas.bottom);
|
||||
StretchBlt(buffer,c->view.left,c->view.top,c->view.right-c->view.left,c->view.bottom-c->view.top,
|
||||
rendered.dc,0,0,rendered.width,rendered.height,SRCCOPY);
|
||||
drawHandles(buffer,*c);
|
||||
RestoreDC(buffer,savedCanvas);
|
||||
BitBlt(dc,0,0,width,height,buffer,0,0,SRCCOPY);
|
||||
}
|
||||
}
|
||||
@@ -1376,6 +1703,11 @@ POINT screenshotTextRotationHandle(const ScreenshotMark &m,double clearance)
|
||||
auto r=plainTextBounds(m);
|
||||
return rotateTextPoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
||||
}
|
||||
POINT screenshotShapeRotationHandle(const ScreenshotMark &m,double clearance)
|
||||
{
|
||||
auto r=region(m.from,m.to);
|
||||
return rotateShapePoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
||||
}
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &m)
|
||||
{
|
||||
ScreenshotMark label{{0,0},{0,0},0};
|
||||
@@ -1455,23 +1787,23 @@ void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
||||
}
|
||||
POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link)
|
||||
{
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
if(m.isText() || (m.shape!=ScreenshotShape::None && m.points.empty())) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))};
|
||||
return m.isText()?rotateTextPoint(m,p):p;
|
||||
return m.isText()?rotateTextPoint(m,p):rotateShapePoint(m,p);
|
||||
}
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
double along=std::clamp(link.u,0.0,1.0)*segments;
|
||||
size_t i=std::min(size_t(along),segments-1); double t=along-i;
|
||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||
return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))};
|
||||
return rotateShapePoint(m,{LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))});
|
||||
}
|
||||
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
||||
{
|
||||
link={m.id,0,0};
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
if(m.isText() || (m.shape!=ScreenshotShape::None && m.points.empty())) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop;
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):rotateShapePoint(m,drop,-1);
|
||||
double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top);
|
||||
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
||||
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
||||
@@ -1491,6 +1823,7 @@ bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,Screen
|
||||
auto anchor=objectAnchor(m,link);
|
||||
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
||||
}
|
||||
drop=rotateShapePoint(m,drop,-1);
|
||||
double best=1e100;
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
for(size_t i=0;i<segments;++i) {
|
||||
@@ -1575,6 +1908,13 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||
}
|
||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
{
|
||||
if(m.shape==ScreenshotShape::Triangle) {
|
||||
auto vertices=shapeOutline(m);
|
||||
for(int i=0;i<3;++i)
|
||||
if(std::hypot(double(p.x-vertices[i].x),double(p.y-vertices[i].y))<=tolerance)
|
||||
return MarkPart(int(MarkPart::Vertex0)+i);
|
||||
}
|
||||
p=rotateShapePoint(m,p,-1);
|
||||
if(m.isText()) {
|
||||
auto r=plainTextBounds(m);
|
||||
auto local=rotateTextPoint(m,p,-1);
|
||||
@@ -1585,8 +1925,10 @@ MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
auto distance=[](POINT a, POINT b) { return std::hypot(double(a.x-b.x),double(a.y-b.y)); };
|
||||
if(!m.points.empty())
|
||||
{
|
||||
if(m.freehand && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
||||
if(m.freehand && distance(p,m.to)<=tolerance) return MarkPart::End;
|
||||
if(m.shape!=ScreenshotShape::Triangle) {
|
||||
if((m.freehand || m.shape!=ScreenshotShape::None) && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
||||
if((m.freehand || m.shape!=ScreenshotShape::None) && distance(p,m.to)<=tolerance) return MarkPart::End;
|
||||
}
|
||||
double reach=tolerance+std::clamp(m.thickness,m.isHighlight()?4:1,m.isHighlight()?100:30)/2.0;
|
||||
POINT from=m.points.front();
|
||||
for(auto to:m.points) {
|
||||
@@ -1640,7 +1982,32 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
{
|
||||
auto m=original;
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(part>=MarkPart::Vertex0 && part<=MarkPart::Vertex2 && m.shape==ScreenshotShape::Triangle) {
|
||||
if(m.points.empty()) { auto local=m; local.rotation=0; m.points=shapeOutline(local); }
|
||||
size_t index=size_t(int(part)-int(MarkPart::Vertex0)),fixed=(index+1)%3;
|
||||
auto fixedWorld=rotateShapePoint(m,m.points[fixed]);
|
||||
auto vertexWorld=rotateShapePoint(m,m.points[index]);
|
||||
POINT target{std::clamp(vertexWorld.x+position.x-origin.x,0L,LONG(width-1)),
|
||||
std::clamp(vertexWorld.y+position.y-origin.y,0L,LONG(height-1))};
|
||||
m.points[index]=rotateShapePoint(m,target,-1); m.points[3]=m.points[0];
|
||||
RECT bounds{m.points[0].x,m.points[0].y,m.points[0].x,m.points[0].y};
|
||||
for(size_t i=1;i<3;++i) {auto p=m.points[i]; bounds.left=std::min(bounds.left,p.x); bounds.right=std::max(bounds.right,p.x);
|
||||
bounds.top=std::min(bounds.top,p.y); bounds.bottom=std::max(bounds.bottom,p.y);}
|
||||
m.from={bounds.left,bounds.top}; m.to={bounds.right,bounds.bottom};
|
||||
auto shifted=rotateShapePoint(m,m.points[fixed]);
|
||||
LONG dx=fixedWorld.x-shifted.x,dy=fixedWorld.y-shifted.y;
|
||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||
for(auto &p:m.points) {p.x+=dx;p.y+=dy;}
|
||||
}
|
||||
else if(part==MarkPart::Rotation && m.shape!=ScreenshotShape::None) {
|
||||
double cx=(m.from.x+m.to.x)/2.0,cy=(m.from.y+m.to.y)/2.0;
|
||||
if(std::hypot(position.x-cx,position.y-cy)<1) return m;
|
||||
double a=std::atan2(origin.y-cy,origin.x-cx),b=std::atan2(position.y-cy,position.x-cx);
|
||||
m.rotation+=(b-a)*180/3.141592653589793;
|
||||
if(snapRotation) m.rotation=std::round(m.rotation/15)*15;
|
||||
m.rotation=std::remainder(m.rotation,360);
|
||||
}
|
||||
else if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
auto r=plainTextBounds(text);
|
||||
@@ -1694,13 +2061,34 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
}
|
||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||
{
|
||||
origin=rotateShapePoint(original,origin,-1);
|
||||
position=rotateShapePoint(original,position,-1);
|
||||
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
||||
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
||||
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
||||
endpoint=constrainScreenshotShape(m.shape,part==MarkPart::Start?m.to:m.from,endpoint,width,height);
|
||||
if(m.shape!=ScreenshotShape::None && !m.points.empty()) {
|
||||
if(m.from.x==m.to.x || m.from.y==m.to.y) return original;
|
||||
// Resize the entire recognized outline, not just a pencil endpoint.
|
||||
// Keep the opposite corner fixed, including when dragging across it.
|
||||
double oldWidth=original.to.x-original.from.x,oldHeight=original.to.y-original.from.y;
|
||||
for(auto &point:m.points) {
|
||||
double u=oldWidth?(point.x-original.from.x)/oldWidth:0;
|
||||
double v=oldHeight?(point.y-original.from.y)/oldHeight:0;
|
||||
point={LONG(std::lround(m.from.x+u*(m.to.x-m.from.x))),
|
||||
LONG(std::lround(m.from.y+v*(m.to.y-m.from.y)))};
|
||||
}
|
||||
}
|
||||
if(m.freehand && !m.points.empty()) (part==MarkPart::Start?m.points.front():m.points.back())=endpoint;
|
||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
|
||||
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
|
||||
LONG dx=fixedBefore.x-fixedAfter.x,dy=fixedBefore.y-fixedAfter.y;
|
||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||
for(auto &p:m.points) {p.x+=dx;p.y+=dy;}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
+4
-3
@@ -15,7 +15,7 @@ void showScreenshot(ScreenshotHost &);
|
||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||
enum class LineEnds { End, Start, Both, None };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid };
|
||||
struct ScreenshotOffset { double x, y; };
|
||||
struct ScreenshotLink {
|
||||
unsigned long long target{};
|
||||
@@ -40,7 +40,7 @@ struct ScreenshotMark {
|
||||
unsigned long long id{};
|
||||
ScreenshotLink fromLink, toLink;
|
||||
bool isText() const { return !text.empty(); }
|
||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
|
||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||
};
|
||||
class ScreenshotHistory {
|
||||
@@ -55,7 +55,7 @@ class ScreenshotHistory {
|
||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||
std::vector<ScreenshotMark> &, double tolerance);
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label, Vertex0, Vertex1, Vertex2 };
|
||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
||||
@@ -65,6 +65,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin
|
||||
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||
POINT screenshotShapeRotationHandle(const ScreenshotMark &, double clearance=32);
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||
// Render at source resolution; never reads or writes the system clipboard.
|
||||
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#include "app.h"
|
||||
#include "screenshot_notifications.h"
|
||||
|
||||
namespace ks {
|
||||
void launchScreenshot(App &app)
|
||||
static void launch(App &app, const Clip *clip)
|
||||
{
|
||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||
auto command=L"\""+path.wstring()+L"\" ";
|
||||
if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip);
|
||||
else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||
if(app.settings.writeLog) command+=L" --write-log";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
}
|
||||
void launchScreenshot(App &app) { launch(app,nullptr); }
|
||||
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
|
||||
}
|
||||
|
||||
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
|
||||
return result;
|
||||
}
|
||||
}
|
||||
std::wstring cacheScreenshotForEditor(const Clip &clip)
|
||||
{
|
||||
// Normalize PNG/DIBV5 and other supported clipboard images at full resolution.
|
||||
auto pixels=decodeImage(clip);
|
||||
if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER))
|
||||
throw std::runtime_error("Image is too large for the screenshot editor");
|
||||
auto dib=imageDib(pixels);
|
||||
auto directory=cacheDirectory(); cleanCache(directory);
|
||||
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||
std::wstring id(value+1,36);
|
||||
atomicWrite(directory/(id+L".capture"),protect(dib.data));
|
||||
return id;
|
||||
}
|
||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||
{
|
||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||
|
||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
||||
DWORD startScreenshotActivationListener();
|
||||
bool validScreenshotToken(const std::wstring &);
|
||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
+243
-330
@@ -1,9 +1,12 @@
|
||||
#include "../resources/resource.h"
|
||||
#include "app.h"
|
||||
#include "history_preview.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "configuration.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <commdlg.h>
|
||||
@@ -34,6 +37,8 @@ struct WindowLayout
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
||||
bool autoOptions{};
|
||||
};
|
||||
WindowLayout *windowLayoutState(HWND w)
|
||||
{
|
||||
@@ -69,17 +74,52 @@ void arrangeWindow(HWND w)
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
RECT client{}; GetClientRect(w, &client);
|
||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
struct Move { HWND child; RECT rect; };
|
||||
std::vector<Move> moves;
|
||||
for (const auto &[child, r] : layout->controls)
|
||||
{
|
||||
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)
|
||||
{
|
||||
next.right += extraWidth;
|
||||
next.bottom += extraHeight;
|
||||
}
|
||||
if (child == layout->details)
|
||||
{
|
||||
next.right += extraWidth;
|
||||
OffsetRect(&next, 0, extraHeight);
|
||||
}
|
||||
if (child == layout->bottomLeft || child == layout->bottomRight)
|
||||
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
||||
if (layout->autoOptions)
|
||||
{
|
||||
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
|
||||
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
|
||||
{
|
||||
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
|
||||
int leftExtra = extraWidth * column / 3;
|
||||
OffsetRect(&next, leftExtra, 0);
|
||||
next.right += extraWidth * (column + 1) / 3 - leftExtra;
|
||||
if (r.top == 328) next.bottom += extraHeight;
|
||||
}
|
||||
else if (r.top == 474 || r.top == 500)
|
||||
{
|
||||
bool rightColumn = r.left >= 290;
|
||||
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
|
||||
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
|
||||
}
|
||||
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
|
||||
OffsetRect(&next, extraWidth, 0);
|
||||
else if (r.right - r.left >= 500)
|
||||
next.right += extraWidth;
|
||||
}
|
||||
RECT current{}; GetWindowRect(child, ¤t);
|
||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
||||
@@ -200,6 +240,9 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
|
||||
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
|
||||
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
|
||||
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
|
||||
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
||||
{
|
||||
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
||||
@@ -279,7 +322,7 @@ std::wstring value(HWND w, int id)
|
||||
text.resize(n);
|
||||
return text;
|
||||
}
|
||||
void value(HWND w, int id, const std::wstring &text)
|
||||
[[maybe_unused]] void value(HWND w, int id, const std::wstring &text)
|
||||
{
|
||||
SetDlgItemTextW(w, id, text.c_str());
|
||||
}
|
||||
@@ -330,11 +373,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
||||
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
||||
SetWindowSubclass(w, layoutProc, 2, 0);
|
||||
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
|
||||
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
||||
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
arrangeWindow(w);
|
||||
SetWindowTextW(w, title);
|
||||
SetWindowTextW(w, localBuildTitle(title).c_str());
|
||||
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
||||
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
|
||||
@@ -440,7 +484,17 @@ struct HistoryView
|
||||
std::map<std::wstring, std::string> richTexts;
|
||||
HWND rich{};
|
||||
std::set<std::wstring> pending;
|
||||
bool scrolling{};
|
||||
};
|
||||
bool selectedImage(HWND w, HistoryView *v)
|
||||
{
|
||||
int i=(int)SendDlgItemMessageW(w,102,LB_GETCURSEL,0,0);
|
||||
return i>=0 && i<(int)v->items.size() && v->items[i].image;
|
||||
}
|
||||
void updateHistoryActions(HWND w, HistoryView *v)
|
||||
{
|
||||
SetDlgItemTextW(w,114,selectedImage(w,v)?L"&Edit image":L"&Open item");
|
||||
}
|
||||
std::wstring selected(HWND w, HistoryView *v)
|
||||
{
|
||||
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
|
||||
@@ -469,6 +523,7 @@ void search(HWND w, HistoryView *v)
|
||||
}
|
||||
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
||||
{
|
||||
if(v->scrolling) return;
|
||||
const auto id = entry.id;
|
||||
const bool image = entry.image;
|
||||
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
|
||||
@@ -652,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
switch (msg)
|
||||
{
|
||||
case ScrollActivity:
|
||||
if((HWND)wp==GetDlgItem(w,102)) {
|
||||
v->scrolling=lp!=0;
|
||||
if(!v->scrolling) InvalidateRect((HWND)wp,nullptr,FALSE);
|
||||
}
|
||||
return 0;
|
||||
case WM_COMMAND:
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
@@ -664,10 +725,18 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
{
|
||||
updateHistoryActions(w,v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||
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)
|
||||
{
|
||||
@@ -709,10 +778,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (result->generation != v->generation)
|
||||
return 0;
|
||||
auto old = selected(w, v);
|
||||
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
||||
v->items = std::move(result->items);
|
||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
||||
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
||||
int select = 0;
|
||||
int restoreTop=0;
|
||||
for (size_t i = 0; i < v->items.size(); ++i)
|
||||
{
|
||||
auto &e = v->items[i];
|
||||
@@ -720,8 +792,11 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
|
||||
if (e.id == old)
|
||||
select = (int)i;
|
||||
if(e.id==topId) restoreTop=(int)i;
|
||||
}
|
||||
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
|
||||
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
|
||||
updateHistoryActions(w,v);
|
||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
|
||||
return 0;
|
||||
@@ -732,16 +807,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (result->generation != v->generation || !IsWindowVisible(w))
|
||||
return 0;
|
||||
v->pending.erase(result->id);
|
||||
// At most ~3.3 MiB, irrespective of history length or source image size.
|
||||
if (v->thumbnails.size() >= 32)
|
||||
v->thumbnails.erase(v->thumbnails.begin());
|
||||
if (v->richTexts.size() >= 32)
|
||||
v->richTexts.erase(v->richTexts.begin());
|
||||
auto list=GetDlgItem(w,102);
|
||||
RECT client{}; GetClientRect(list,&client);
|
||||
std::set<std::wstring> visible;
|
||||
RECT changed{}; bool repaint=false;
|
||||
int top=(int)SendMessageW(list,LB_GETTOPINDEX,0,0);
|
||||
for(int i=std::max(0,top);i<(int)v->items.size();++i) {
|
||||
RECT row{}, intersection{};
|
||||
if(SendMessageW(list,LB_GETITEMRECT,i,(LPARAM)&row)==LB_ERR || row.top>=client.bottom) break;
|
||||
if(!IntersectRect(&intersection,&row,&client)) continue;
|
||||
visible.insert(v->items[i].id);
|
||||
if(v->items[i].id==result->id) { changed=intersection; repaint=true; }
|
||||
}
|
||||
if (!result->rtf.empty())
|
||||
v->richTexts[result->id] = std::move(result->rtf);
|
||||
else
|
||||
v->thumbnails[result->id] = std::move(result->pixels);
|
||||
InvalidateRect(GetDlgItem(w, 102), nullptr, FALSE);
|
||||
trimHistoryPreviews(v->thumbnails,visible);
|
||||
trimHistoryPreviews(v->richTexts,visible);
|
||||
if(repaint) InvalidateRect(list,&changed,FALSE);
|
||||
return 0;
|
||||
}
|
||||
case WM_MEASUREITEM:
|
||||
@@ -760,15 +844,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case WM_CONTEXTMENU:
|
||||
{
|
||||
auto list=GetDlgItem(w,102);
|
||||
if((HWND)wp!=list) break;
|
||||
POINT p{(short)LOWORD(lp),(short)HIWORD(lp)};
|
||||
if(lp!=-1) {
|
||||
POINT hit=p; ScreenToClient(list,&hit);
|
||||
auto item=SendMessageW(list,LB_ITEMFROMPOINT,0,MAKELPARAM(hit.x,hit.y));
|
||||
if(HIWORD(item)) return 0;
|
||||
SendMessageW(list,LB_SETCURSEL,LOWORD(item),0);
|
||||
updateHistoryActions(w,v);
|
||||
} else {
|
||||
RECT row{};
|
||||
auto index=SendMessageW(list,LB_GETCURSEL,0,0);
|
||||
if(index==LB_ERR) return 0;
|
||||
SendMessageW(list,LB_GETITEMRECT,index,(LPARAM)&row);
|
||||
p={row.left,row.top}; ClientToScreen(list,&p);
|
||||
}
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
|
||||
POINT p;
|
||||
GetCursorPos(&p);
|
||||
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)
|
||||
@@ -1021,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);
|
||||
@@ -1032,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)
|
||||
@@ -1062,14 +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);
|
||||
@@ -1082,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));
|
||||
@@ -1100,10 +1221,25 @@ 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);
|
||||
initial.bottom += scale(w, 140);
|
||||
fitToMonitor(w, initial);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
@@ -1181,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)
|
||||
@@ -1237,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);
|
||||
@@ -1337,6 +1475,14 @@ struct SnippetView
|
||||
std::vector<Snippet> snippets;
|
||||
int selection = -1;
|
||||
};
|
||||
void updateSnippetEditor(HWND w, SnippetView *v)
|
||||
{
|
||||
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
||||
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
|
||||
value(w, 303, selected ? v->snippets[v->selection].text : L"");
|
||||
for (int id : {302, 303, 311, 312, 313})
|
||||
EnableWindow(GetDlgItem(w, id), selected);
|
||||
}
|
||||
void fillSnippets(HWND w, SnippetView *v)
|
||||
{
|
||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
||||
@@ -1344,12 +1490,16 @@ void fillSnippets(HWND w, SnippetView *v)
|
||||
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
||||
if (v->selection >= 0)
|
||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
}
|
||||
void collectSnippet(HWND w, SnippetView *v)
|
||||
{
|
||||
if (v->selection >= 0 && v->selection < (int)v->snippets.size())
|
||||
{
|
||||
v->snippets[v->selection] = {value(w, 302), value(w, 303)};
|
||||
auto &snippet = v->snippets[v->selection];
|
||||
if (snippet.id.empty()) snippet.id = snippetId(snippet);
|
||||
snippet.trigger = value(w, 302);
|
||||
snippet.text = value(w, 303);
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
@@ -1380,21 +1530,15 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
value(w, 302, v->snippets[v->selection].trigger);
|
||||
value(w, 303, v->snippets[v->selection].text);
|
||||
}
|
||||
updateSnippetEditor(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 310)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->snippets.push_back({L";new", L""});
|
||||
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 302, L";new");
|
||||
value(w, 303, L"");
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return 0;
|
||||
}
|
||||
@@ -1405,14 +1549,14 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 302, L"");
|
||||
value(w, 303, L"");
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (id == 312)
|
||||
if (id == 312 || id == 313)
|
||||
{
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}");
|
||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK)
|
||||
@@ -1501,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);
|
||||
}
|
||||
@@ -1530,251 +1675,11 @@ void showHistory(App &app, bool visible)
|
||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||
refreshHistory(app);
|
||||
}
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
LogClear = 606;
|
||||
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
// Header notifications go to the ListView, not its top-level parent.
|
||||
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
|
||||
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
|
||||
{
|
||||
auto *draw = (NMCUSTOMDRAW *)lp;
|
||||
if (draw->dwDrawStage == CDDS_PREPAINT)
|
||||
{
|
||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||
return CDRF_NOTIFYITEMDRAW;
|
||||
}
|
||||
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
|
||||
{
|
||||
int saved = SaveDC(draw->hdc);
|
||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||
RECT edge = draw->rc;
|
||||
edge.top = edge.bottom - 1;
|
||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
||||
edge = draw->rc;
|
||||
edge.left = edge.right - 1;
|
||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
||||
wchar_t title[128]{};
|
||||
HDITEMW item{};
|
||||
item.mask = HDI_TEXT;
|
||||
item.pszText = title;
|
||||
item.cchTextMax = 128;
|
||||
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
|
||||
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
|
||||
if (headerFont)
|
||||
SelectObject(draw->hdc, headerFont);
|
||||
SetBkMode(draw->hdc, TRANSPARENT);
|
||||
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
|
||||
RECT text = draw->rc;
|
||||
InflateRect(&text, -scale(w, 6), 0);
|
||||
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||
RestoreDC(draw->hdc, saved);
|
||||
return CDRF_SKIPDEFAULT;
|
||||
}
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
RemoveWindowSubclass(w, logListProc, 1);
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
}
|
||||
void copyLogRows(HWND w, App &app)
|
||||
{
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
std::wstring copied;
|
||||
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
||||
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
||||
{
|
||||
for (int column = 0; column < 4; ++column)
|
||||
{
|
||||
std::vector<wchar_t> text(32768);
|
||||
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
|
||||
if (column)
|
||||
copied += L'\t';
|
||||
copied += text.data();
|
||||
}
|
||||
copied += L"\r\n";
|
||||
}
|
||||
if (!copied.empty())
|
||||
app.ownSequence = writeClipboard(app.window, textClip(copied));
|
||||
}
|
||||
void refreshLogView(HWND w, App &app)
|
||||
{
|
||||
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
|
||||
std::wstring contents;
|
||||
try
|
||||
{
|
||||
contents = readDiagnosticLog(previous);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
contents = L"Could not read the log: " + wide(e.what());
|
||||
}
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||
ListView_DeleteAllItems(list);
|
||||
std::wistringstream lines(contents);
|
||||
std::wstring line, timestamp;
|
||||
int row = 0;
|
||||
while (std::getline(lines, line))
|
||||
{
|
||||
if (!line.empty() && line.back() == L'\r')
|
||||
line.pop_back();
|
||||
if (line.empty())
|
||||
continue;
|
||||
auto firstSpace = line.find(L' ');
|
||||
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
|
||||
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
|
||||
{
|
||||
timestamp = line.substr(0, secondSpace);
|
||||
line.erase(0, secondSpace + 1);
|
||||
}
|
||||
auto id = line.find(L" id=");
|
||||
std::wstring attempt;
|
||||
if (id != line.npos)
|
||||
{
|
||||
auto end = line.find(L' ', id + 4);
|
||||
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
|
||||
}
|
||||
std::wstring event = line;
|
||||
if (event.starts_with(L" ageMs="))
|
||||
{
|
||||
auto end = event.find(L' ', 8);
|
||||
if (end != event.npos)
|
||||
event.erase(0, end + 1);
|
||||
}
|
||||
auto equals = event.find(L'=');
|
||||
if (equals != event.npos)
|
||||
{
|
||||
auto key = event.rfind(L' ', equals);
|
||||
if (key != event.npos)
|
||||
event.resize(key);
|
||||
}
|
||||
LVITEMW item{};
|
||||
item.mask = LVIF_TEXT;
|
||||
item.iItem = row;
|
||||
item.pszText = timestamp.data();
|
||||
ListView_InsertItem(list, &item);
|
||||
ListView_SetItemText(list, row, 1, attempt.data());
|
||||
ListView_SetItemText(list, row, 2, event.data());
|
||||
ListView_SetItemText(list, row, 3, line.data());
|
||||
++row;
|
||||
}
|
||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
if (row)
|
||||
ListView_EnsureVisible(list, row - 1, FALSE);
|
||||
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
||||
: L"Logging disabled. Showing previously saved entries.");
|
||||
}
|
||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (msg == WM_NCCREATE)
|
||||
{
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
||||
return TRUE;
|
||||
}
|
||||
if (!app)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
switch (msg)
|
||||
{
|
||||
case WM_COMMAND:
|
||||
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
||||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
||||
{
|
||||
refreshLogView(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try { copyLogRows(w, *app); }
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try
|
||||
{
|
||||
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
refreshLogView(w, *app);
|
||||
}
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
||||
break;
|
||||
[[fallthrough]];
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
case WM_NOTIFY:
|
||||
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
|
||||
{
|
||||
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
||||
if (GetKeyState(VK_CONTROL) & 0x8000)
|
||||
{
|
||||
if (key == 'A')
|
||||
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
||||
if (key == 'C')
|
||||
{
|
||||
try { copyLogRows(w, *app); }
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DPICHANGED:
|
||||
resizeForDpi(w, wp, lp);
|
||||
return 0;
|
||||
case WM_NCDESTROY:
|
||||
app->logWindow = nullptr;
|
||||
disposeFont(w);
|
||||
break;
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
}
|
||||
void showLogs(App &app)
|
||||
{
|
||||
if (app.logWindow)
|
||||
{
|
||||
showRequestedWindow(app.logWindow);
|
||||
activateWindow(app.logWindow);
|
||||
return;
|
||||
}
|
||||
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
|
||||
app.logWindow = w;
|
||||
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||
button(w, L"Refresh", LogRefresh, 330, 14, 110);
|
||||
button(w, L"Copy selected", LogCopy, 454, 14, 140);
|
||||
button(w, L"Clear log", LogClear, 608, 14, 110);
|
||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
|
||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&common);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
LogRows, 16, 82, 868, 454);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
||||
SetWindowSubclass(list, logListProc, 1, 0);
|
||||
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
||||
const int widths[] = {155, 65, 195, 900};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
LVCOLUMNW column{};
|
||||
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
||||
column.pszText = const_cast<wchar_t *>(titles[i]);
|
||||
column.cx = scale(w, widths[i]);
|
||||
ListView_InsertColumn(list, i, &column);
|
||||
}
|
||||
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
|
||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
#include "ui_log.inl"
|
||||
#else
|
||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
void showSettings(App &app, bool visible)
|
||||
@@ -1791,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"};
|
||||
@@ -1806,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);
|
||||
@@ -1825,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)
|
||||
{
|
||||
@@ -1855,16 +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);
|
||||
label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488);
|
||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
||||
button(w, L"Save", IDOK, 510, 478, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
||||
button(w, L"Insert cursor &marker", 312, 236, 430, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"&Save", IDOK, 510, 478, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 624, 478, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
@@ -1892,11 +1799,17 @@ void showEntry(App &app, const std::wstring &id)
|
||||
try
|
||||
{
|
||||
auto clip = app.history->load(id);
|
||||
if(std::any_of(clip.formats.begin(),clip.formats.end(),[](const auto &format) {
|
||||
return format.id==CF_DIB || format.id==CF_DIBV5 || format.name==L"PNG";
|
||||
})) {
|
||||
launchScreenshotEditor(app,clip);
|
||||
return;
|
||||
}
|
||||
auto text = clip.text();
|
||||
if (text.empty())
|
||||
{
|
||||
MessageBoxW(app.historyWindow,
|
||||
L"This item contains an image or files. Use the preview or paste it into the "
|
||||
L"This item contains files. Paste it into the "
|
||||
L"destination application.",
|
||||
L"Clipboard item", MB_OK);
|
||||
return;
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
// Included inside namespace ks by ui.cpp; shares the native control/layout helpers.
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
LogClear = 606, LogProgram = 607, LogCommand = 608, LogResult = 609, LogProblems = 610,
|
||||
LogDetails = 611, LogReset = 612;
|
||||
struct LogViewState
|
||||
{
|
||||
std::vector<OperationLogRow> rows;
|
||||
std::vector<size_t> visible;
|
||||
int sortColumn = 0;
|
||||
bool descending = true, rendering = false;
|
||||
};
|
||||
LogViewState *logState(HWND w)
|
||||
{
|
||||
return reinterpret_cast<LogViewState *>(GetPropW(w, L"LogViewState"));
|
||||
}
|
||||
const std::wstring &logCell(const OperationLogRow &row, int column)
|
||||
{
|
||||
switch (column)
|
||||
{
|
||||
case 0:
|
||||
return row.time;
|
||||
case 1:
|
||||
return row.process;
|
||||
case 2:
|
||||
return row.command;
|
||||
case 3:
|
||||
return row.source;
|
||||
case 4:
|
||||
return row.target;
|
||||
default:
|
||||
return row.outcome;
|
||||
}
|
||||
}
|
||||
void showLogDetails(HWND w)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state || state->rendering)
|
||||
return;
|
||||
int selected = ListView_GetNextItem(GetDlgItem(w, LogRows), -1, LVNI_SELECTED);
|
||||
value(w, LogDetails,
|
||||
selected >= 0 && (size_t)selected < state->visible.size()
|
||||
? state->rows[state->visible[selected]].details
|
||||
: L"Select an operation to inspect its text and stages.");
|
||||
}
|
||||
void renderLogRows(HWND w, App &app)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state)
|
||||
return;
|
||||
state->rendering = true;
|
||||
state->visible.clear();
|
||||
auto matches = [&](int id, const std::wstring &field)
|
||||
{ return SendDlgItemMessageW(w, id, CB_GETCURSEL, 0, 0) <= 0 || value(w, id) == field; };
|
||||
for (size_t i = 0; i < state->rows.size(); ++i)
|
||||
{
|
||||
const auto &row = state->rows[i];
|
||||
if (matches(LogProgram, row.process) && matches(LogCommand, row.command) &&
|
||||
matches(LogResult, row.outcome) && (!checked(w, LogProblems) || row.problem))
|
||||
state->visible.push_back(i);
|
||||
}
|
||||
std::stable_sort(state->visible.begin(), state->visible.end(),
|
||||
[&](size_t a, size_t b)
|
||||
{
|
||||
const auto &left = logCell(state->rows[a], state->sortColumn);
|
||||
const auto &right = logCell(state->rows[b], state->sortColumn);
|
||||
if (left == right)
|
||||
return state->descending ? a > b : a < b;
|
||||
int comparison = CompareStringOrdinal(left.c_str(), -1, right.c_str(), -1, TRUE);
|
||||
return state->descending ? comparison == CSTR_GREATER_THAN
|
||||
: comparison == CSTR_LESS_THAN;
|
||||
});
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||
ListView_DeleteAllItems(list);
|
||||
for (size_t i = 0; i < state->visible.size(); ++i)
|
||||
{
|
||||
const auto &row = state->rows[state->visible[i]];
|
||||
LVITEMW item{};
|
||||
item.mask = LVIF_TEXT;
|
||||
item.iItem = (int)i;
|
||||
item.pszText = const_cast<wchar_t *>(row.time.c_str());
|
||||
ListView_InsertItem(list, &item);
|
||||
for (int column = 1; column < 6; ++column)
|
||||
ListView_SetItemText(list, (int)i, column, const_cast<wchar_t *>(logCell(row, column).c_str()));
|
||||
}
|
||||
HWND header = ListView_GetHeader(list);
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
HDITEMW item{};
|
||||
item.mask = HDI_FORMAT;
|
||||
Header_GetItem(header, i, &item);
|
||||
item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN);
|
||||
if (i == state->sortColumn)
|
||||
item.fmt |= state->descending ? HDF_SORTDOWN : HDF_SORTUP;
|
||||
Header_SetItem(header, i, &item);
|
||||
}
|
||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
state->rendering = false;
|
||||
value(w, LogStatus,
|
||||
std::to_wstring(state->visible.size()) + L" of " + std::to_wstring(state->rows.size()) +
|
||||
L" operations / events. " +
|
||||
(app.settings.writeLog ? L"Diagnostic logging enabled."
|
||||
: L"Logging disabled; saved entries only."));
|
||||
if (!state->visible.empty())
|
||||
ListView_SetItemState(list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||
showLogDetails(w);
|
||||
if (state->visible.empty())
|
||||
value(w, LogDetails,
|
||||
state->rows.empty()
|
||||
? L"No recorded operations. Use a conversion shortcut with logging enabled, then refresh."
|
||||
: L"No operations match these filters. Reset filters to show all entries.");
|
||||
}
|
||||
void fillLogFilter(HWND w, int id, const std::set<std::wstring> &choices)
|
||||
{
|
||||
auto previous = value(w, id);
|
||||
HWND combo = GetDlgItem(w, id);
|
||||
SendMessageW(combo, CB_RESETCONTENT, 0, 0);
|
||||
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"All");
|
||||
int selected = 0, index = 1;
|
||||
for (const auto &choice : choices)
|
||||
{
|
||||
if (choice.empty())
|
||||
continue;
|
||||
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)choice.c_str());
|
||||
if (choice == previous)
|
||||
selected = index;
|
||||
++index;
|
||||
}
|
||||
SendMessageW(combo, CB_SETCURSEL, selected, 0);
|
||||
}
|
||||
void refreshLogView(HWND w, App &app)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state)
|
||||
return;
|
||||
try
|
||||
{
|
||||
auto contents = readDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
state->rows = parseOperationLog(contents);
|
||||
if (contents.starts_with(L"No log yet.") || contents.starts_with(L"No previous log") ||
|
||||
contents == L"The log is empty.")
|
||||
state->rows.clear();
|
||||
std::set<std::wstring> programs, commands, results;
|
||||
for (const auto &row : state->rows)
|
||||
{
|
||||
programs.insert(row.process);
|
||||
commands.insert(row.command);
|
||||
results.insert(row.outcome);
|
||||
}
|
||||
fillLogFilter(w, LogProgram, programs);
|
||||
fillLogFilter(w, LogCommand, commands);
|
||||
fillLogFilter(w, LogResult, results);
|
||||
renderLogRows(w, app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
value(w, LogStatus, L"Could not read the log: " + wide(e.what()));
|
||||
}
|
||||
}
|
||||
void copyLogRows(HWND w, App &app)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state)
|
||||
return;
|
||||
std::wstring copied;
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
||||
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
||||
{
|
||||
if ((size_t)row >= state->visible.size())
|
||||
continue;
|
||||
const auto &entry = state->rows[state->visible[row]];
|
||||
copied += entry.time + L"\t" + entry.process + L"\t" + entry.command + L"\t" + entry.source +
|
||||
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
|
||||
}
|
||||
if (!copied.empty())
|
||||
{
|
||||
auto clip = textClip(copied);
|
||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||
app.ownSequence = writeClipboard(app.window, clip);
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (msg == WM_NCCREATE)
|
||||
{
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
||||
SetPropW(w, L"LogViewState", new LogViewState);
|
||||
return TRUE;
|
||||
}
|
||||
if (!app)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
switch (msg)
|
||||
{
|
||||
case WM_COMMAND:
|
||||
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
||||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
||||
{
|
||||
refreshLogView(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if ((LOWORD(wp) >= LogProgram && LOWORD(wp) <= LogResult && HIWORD(wp) == CBN_SELCHANGE) ||
|
||||
(LOWORD(wp) == LogProblems && HIWORD(wp) == BN_CLICKED))
|
||||
{
|
||||
renderLogRows(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogReset && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
for (int id : {LogProgram, LogCommand, LogResult})
|
||||
SendDlgItemMessageW(w, id, CB_SETCURSEL, 0, 0);
|
||||
CheckDlgButton(w, LogProblems, BST_UNCHECKED);
|
||||
renderLogRows(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try
|
||||
{
|
||||
copyLogRows(w, *app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try
|
||||
{
|
||||
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
refreshLogView(w, *app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
||||
break;
|
||||
[[fallthrough]];
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
case WM_NOTIFY:
|
||||
if (((NMHDR *)lp)->idFrom != LogRows)
|
||||
break;
|
||||
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
|
||||
showLogDetails(w);
|
||||
if (((NMHDR *)lp)->code == LVN_COLUMNCLICK)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
int column = ((NMLISTVIEW *)lp)->iSubItem;
|
||||
state->descending = state->sortColumn == column ? !state->descending : false;
|
||||
state->sortColumn = column;
|
||||
renderLogRows(w, *app);
|
||||
}
|
||||
if (((NMHDR *)lp)->code == LVN_KEYDOWN)
|
||||
{
|
||||
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
||||
if (GetKeyState(VK_CONTROL) & 0x8000)
|
||||
{
|
||||
if (key == 'A')
|
||||
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
||||
if (key == 'C')
|
||||
{
|
||||
try
|
||||
{
|
||||
copyLogRows(w, *app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DPICHANGED:
|
||||
resizeForDpi(w, wp, lp);
|
||||
return 0;
|
||||
case WM_NCDESTROY:
|
||||
delete logState(w);
|
||||
RemovePropW(w, L"LogViewState");
|
||||
app->logWindow = nullptr;
|
||||
disposeFont(w);
|
||||
break;
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
} // namespace
|
||||
void showLogs(App &app)
|
||||
{
|
||||
if (app.logWindow)
|
||||
{
|
||||
showRequestedWindow(app.logWindow);
|
||||
activateWindow(app.logWindow);
|
||||
return;
|
||||
}
|
||||
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher diagnostic operations", logProc, 1100, 760);
|
||||
app.logWindow = w;
|
||||
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 280, 140);
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||
button(w, L"&Refresh", LogRefresh, 310, 14, 100);
|
||||
button(w, L"&Copy selected", LogCopy, 424, 14, 140);
|
||||
button(w, L"Clear &log", LogClear, 578, 14, 100);
|
||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
|
||||
label(w, L"&Program", 16, 78, 170);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
|
||||
label(w, L"Co&mmand", 206, 78, 200);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 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);
|
||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&common);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
LogRows, 16, 142, 1068, 260);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER |
|
||||
LVS_EX_INFOTIP);
|
||||
const wchar_t *titles[] = {L"Time", L"Program", L"Command",
|
||||
L"Source layout", L"Target layout", L"Result"};
|
||||
const int widths[] = {165, 145, 150, 130, 130, 320};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
LVCOLUMNW column{};
|
||||
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
||||
column.pszText = const_cast<wchar_t *>(titles[i]);
|
||||
column.cx = scale(w, widths[i]);
|
||||
ListView_InsertColumn(list, i, &column);
|
||||
}
|
||||
auto details = edit(w, LogDetails, 16, 416, 1068, 276,
|
||||
ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL | WS_BORDER);
|
||||
auto hint = control(
|
||||
w, L"STATIC",
|
||||
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
|
||||
16, 712, 860, 22);
|
||||
button(w, L"Clos&e", IDCANCEL, 974, 708, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->details = details;
|
||||
layout->bottomLeft = hint;
|
||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||
arrangeWindow(w);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
namespace ks {
|
||||
inline constexpr UINT ScrollActivity=WM_APP+42;
|
||||
inline constexpr UINT_PTR ScrollFrameTimer=0x4b5301, ScrollIdleTimer=0x4b5302;
|
||||
struct WheelBatch {
|
||||
long long vertical{}, horizontal{};
|
||||
WORD keys{};
|
||||
LPARAM point{};
|
||||
bool frame{}, active{}, listBox{};
|
||||
int remainder{};
|
||||
};
|
||||
inline LRESULT CALLBACK wheelBatchProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
|
||||
{
|
||||
auto *batch=reinterpret_cast<WheelBatch *>(data);
|
||||
if(msg==WM_MOUSEWHEEL || msg==WM_MOUSEHWHEEL) {
|
||||
(msg==WM_MOUSEWHEEL?batch->vertical:batch->horizontal)+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||
batch->keys=GET_KEYSTATE_WPARAM(wp); batch->point=lp;
|
||||
if(!batch->active) {
|
||||
batch->active=true;
|
||||
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,TRUE);
|
||||
}
|
||||
// Do not restart the frame timer: an uninterrupted wheel stream must
|
||||
// keep moving, rather than wait for a debounce interval to expire.
|
||||
if(!batch->frame) batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||
SetTimer(w,ScrollIdleTimer,100,nullptr);
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_TIMER && wp==ScrollFrameTimer) {
|
||||
KillTimer(w,ScrollFrameTimer); batch->frame=false;
|
||||
if(batch->listBox && batch->vertical) {
|
||||
UINT lines=3; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||
if(lines==WHEEL_PAGESCROLL) {
|
||||
RECT client{}; GetClientRect(w,&client);
|
||||
auto height=SendMessageW(w,LB_GETITEMHEIGHT,0,0);
|
||||
lines=std::max<LRESULT>(1,client.bottom/std::max<LRESULT>(1,height));
|
||||
}
|
||||
auto delta=batch->vertical+batch->remainder;
|
||||
batch->remainder=(int)(delta%WHEEL_DELTA);
|
||||
auto top=SendMessageW(w,LB_GETTOPINDEX,0,0);
|
||||
auto count=SendMessageW(w,LB_GETCOUNT,0,0);
|
||||
auto next=std::clamp<long long>(top-(delta/WHEEL_DELTA)*lines,0,std::max<LRESULT>(0,count-1));
|
||||
if(next!=top) SendMessageW(w,LB_SETTOPINDEX,(WPARAM)next,0);
|
||||
batch->vertical=0;
|
||||
} else if(batch->vertical) {
|
||||
// Native edit/list-view/window handlers retain their own scrolling
|
||||
// semantics, including system settings and partial wheel deltas.
|
||||
auto delta=(short)std::clamp<long long>(batch->vertical,-32760,32760);
|
||||
batch->vertical-=delta;
|
||||
DefSubclassProc(w,WM_MOUSEWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||
}
|
||||
if(batch->horizontal) {
|
||||
auto delta=(short)std::clamp<long long>(batch->horizontal,-32760,32760);
|
||||
batch->horizontal-=delta;
|
||||
DefSubclassProc(w,WM_MOUSEHWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||
}
|
||||
if(batch->vertical || batch->horizontal)
|
||||
batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_TIMER && wp==ScrollIdleTimer) {
|
||||
if(batch->frame) return 0;
|
||||
KillTimer(w,ScrollIdleTimer); batch->active=false;
|
||||
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,FALSE);
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_NCDESTROY) {
|
||||
KillTimer(w,ScrollFrameTimer); KillTimer(w,ScrollIdleTimer);
|
||||
RemoveWindowSubclass(w,wheelBatchProc,id); delete batch;
|
||||
}
|
||||
return DefSubclassProc(w,msg,wp,lp);
|
||||
}
|
||||
inline void enableWheelBatching(HWND window,bool listBox=false)
|
||||
{
|
||||
if(!window) return;
|
||||
auto *batch=new WheelBatch; batch->listBox=listBox;
|
||||
if(!SetWindowSubclass(window,wheelBatchProc,ScrollFrameTimer,(DWORD_PTR)batch)) delete batch;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
+5
-4
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
|
||||
return out + L'"';
|
||||
}
|
||||
}
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
|
||||
{
|
||||
UpdateResult result;
|
||||
GUID guid{};
|
||||
@@ -56,6 +56,7 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||
if (showProgress) command += L" -ShowProgress";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||
@@ -94,10 +95,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void confirmUpdatedStartup(const std::wstring &eventName)
|
||||
bool confirmUpdatedStartup(const std::wstring &eventName)
|
||||
{
|
||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
|
||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||
if (event.value) SetEvent(event.value);
|
||||
return event.value && SetEvent(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.10";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.29";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
@@ -13,6 +13,6 @@ struct UpdateResult
|
||||
std::wstring state, version, error;
|
||||
bool interactive{}, install{};
|
||||
};
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
||||
void confirmUpdatedStartup(const std::wstring &eventName);
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
|
||||
bool confirmUpdatedStartup(const std::wstring &eventName);
|
||||
}
|
||||
|
||||
+52
-2
@@ -1,4 +1,4 @@
|
||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
|
||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||
@@ -18,6 +18,7 @@ public static class ConfigurationTestUi {
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
||||
@@ -38,7 +39,7 @@ $checks=0
|
||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||
function Window($class) {
|
||||
for($i=0;$i -lt 100;$i++) {
|
||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
|
||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
|
||||
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||
if($window -ne [IntPtr]::Zero){return $window}
|
||||
Start-Sleep -Milliseconds 50
|
||||
@@ -56,6 +57,24 @@ function Launch($file,$argument) {
|
||||
function Click($window,$id) {
|
||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
||||
}
|
||||
function TestLogs {
|
||||
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
|
||||
$config=Launch 'KSwitcher.Config.exe' '--logs'
|
||||
$logs=Window 'KSwitcher.Logs'
|
||||
[uint32]$logOwner=0
|
||||
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
|
||||
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
|
||||
$second=Launch 'KSwitcher.Config.exe' '--logs'
|
||||
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
|
||||
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
|
||||
Click $logs 603
|
||||
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
|
||||
Click $logs 606
|
||||
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
|
||||
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
|
||||
Click $logs 2
|
||||
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
|
||||
}
|
||||
try {
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||
@@ -67,6 +86,13 @@ try {
|
||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||
$resident=Launch 'KSwitcher.exe' ''
|
||||
$main=Window 'KSwitcher.Main'
|
||||
if ($LogsOnly) {
|
||||
TestLogs
|
||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
|
||||
"Passed $checks log process checks"
|
||||
return
|
||||
}
|
||||
$config=Launch 'KSwitcher.Config.exe' ''
|
||||
$settings=Window 'KSwitcher.Settings'
|
||||
[uint32]$ownerId=0
|
||||
@@ -102,6 +128,29 @@ try {
|
||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
||||
$snippets=Window 'KSwitcher.Snippets'
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
foreach($id in @(302,303,311,312,313)) {
|
||||
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
|
||||
}
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||
foreach($id in @(302,303,311,312,313)) {
|
||||
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
|
||||
}
|
||||
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
|
||||
foreach($id in @(302,303,311,312,313)) {
|
||||
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
|
||||
}
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
|
||||
$snippetText=[Text.StringBuilder]::new(128)
|
||||
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
|
||||
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
|
||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
|
||||
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
|
||||
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
|
||||
$second=Launch 'KSwitcher.Config.exe' ''
|
||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||
$settings=Window 'KSwitcher.Settings'
|
||||
@@ -110,6 +159,7 @@ try {
|
||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||
Click $snippets 2
|
||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||
TestLogs
|
||||
$config=Launch 'KSwitcher.Config.exe' ''
|
||||
$null=Window 'KSwitcher.Settings'
|
||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
|
||||
+184
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
#include <commctrl.h>
|
||||
@@ -14,6 +15,37 @@ struct InputRegressionAccess {
|
||||
static inline bool delivery = true;
|
||||
static inline int notifications = 0;
|
||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||
static void sampleConflicts(App &a) {
|
||||
a.conflictConnection=loadSyncConfig(a.data);
|
||||
a.pendingSyncConflicts={{"/autoCorrectMinLength",L"Minimum word length",{'4'},{'5'}}};
|
||||
}
|
||||
static bool staleSync(App &a, bool connectionChanged) {
|
||||
auto before=encodeSettings(a.settings);
|
||||
a.syncConnection=loadSyncConfig(a.data);
|
||||
a.syncSnapshot=before;
|
||||
if(connectionChanged) a.syncConnection.enabled=!a.syncConnection.enabled;
|
||||
else a.syncSnapshot.clear();
|
||||
auto remote=a.settings; remote.recent=!remote.recent;
|
||||
a.syncResult={true,sharedSyncSettings(remote),2,L"test"};
|
||||
a.syncWorker=std::thread([]{});
|
||||
a.syncRunning=true; a.syncDone=true;
|
||||
a.pollSync();
|
||||
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
||||
!std::filesystem::exists(a.data/L"sync-base.bin");
|
||||
}
|
||||
static bool syncBackoff(App &a) {
|
||||
a.syncRetryAfter=GetTickCount64()+15*60000;
|
||||
auto previous=a.settings;
|
||||
auto next=previous;
|
||||
next.toggle=next.casing=next.translit=next.undo=next.clipboard=next.screenshot=L"";
|
||||
next.direct.clear(); next.directIds.clear();
|
||||
// This harness deliberately has no global keyboard hook.
|
||||
a.applySettings(next);
|
||||
bool preserved=a.nextSync==a.syncRetryAfter;
|
||||
a.settings=previous;
|
||||
a.syncRetryAfter=0;
|
||||
return preserved;
|
||||
}
|
||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
static bool updateTimer(App &a) {
|
||||
@@ -55,6 +87,14 @@ int main(int argc, char **argv) {
|
||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||
require(app.window!=nullptr,"message-only test window");
|
||||
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
||||
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
||||
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
||||
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
|
||||
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
|
||||
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
|
||||
"successful startup signals the updater before reporting completion");
|
||||
CloseHandle(started);
|
||||
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
||||
@@ -559,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)})
|
||||
@@ -586,10 +629,137 @@ 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");
|
||||
ShowWindow(autoOptions,SW_HIDE);
|
||||
{
|
||||
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
|
||||
"auto-correction dialog supports resizing");
|
||||
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
|
||||
GetWindowRect(autoOptions,&original);
|
||||
// Use a client size above the scrolling baseline at every DPI.
|
||||
UINT dpi=GetDpiForWindow(autoOptions);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(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]);
|
||||
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==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,
|
||||
"resized dictionary fields and footer do not overlap");
|
||||
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
|
||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
}
|
||||
for(int id:{411,412,413}) {
|
||||
RECT control{},client{};
|
||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||
@@ -599,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);
|
||||
@@ -608,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);
|
||||
@@ -691,16 +870,21 @@ int main(int argc, char **argv) {
|
||||
controlMoves=0;
|
||||
ValidateRect(settings,nullptr);
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
require(controlMoves==0,"wheel changes wait for the shared frame timer");
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||
controlMoves=0;
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
||||
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||
|
||||
+428
-10
@@ -1,4 +1,6 @@
|
||||
#include "app.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <commctrl.h>
|
||||
@@ -20,6 +22,70 @@ ks::App *app{};
|
||||
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
|
||||
// make the fixture silently discard the operation under test.
|
||||
struct ClipboardAccess
|
||||
{
|
||||
bool opened = false;
|
||||
~ClipboardAccess() { if (opened) CloseClipboard(); }
|
||||
} access;
|
||||
if (message == WM_CHAR && (wp == 3 || wp == 22))
|
||||
{
|
||||
auto deadline = GetTickCount64() + 1000;
|
||||
do
|
||||
{
|
||||
if (OpenClipboard(w)) { access.opened = true; break; }
|
||||
Sleep(10);
|
||||
} while (GetTickCount64() < deadline);
|
||||
if (!access.opened)
|
||||
{
|
||||
clipboardAccessTimedOut = true;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
|
||||
{
|
||||
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
|
||||
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
|
||||
if (message == WM_CHAR)
|
||||
{
|
||||
++copyRequests;
|
||||
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
|
||||
// Reproduce that outcome deterministically, without depending on
|
||||
// the timing of the Windows clipboard-history service.
|
||||
if (copyFailuresRemaining > 0)
|
||||
{
|
||||
--copyFailuresRemaining;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message == WM_PASTE && pasteBehavior)
|
||||
{
|
||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||||
return DefSubclassProc(w, message, wp, lp);
|
||||
}
|
||||
int stage = 0, ru = -1, en = -1;
|
||||
size_t punctuationCase = 0;
|
||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||
@@ -36,13 +102,15 @@ std::string diagnosticLog()
|
||||
size_t diagnosticStart{};
|
||||
void require(bool ok, const char *name)
|
||||
{
|
||||
if (!ok)
|
||||
if (!ok || clipboardAccessTimedOut)
|
||||
{
|
||||
wchar_t observed[2048]{};
|
||||
GetWindowTextW(field, observed, 2048);
|
||||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
||||
if (clipboardAccessTimedOut)
|
||||
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
|
||||
throw std::runtime_error(name);
|
||||
}
|
||||
std::cout << "PASS " << name << '\n';
|
||||
@@ -94,6 +162,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
if (msg == WM_TIMER)
|
||||
{
|
||||
// Input tests must not start a network update check. Its close veto can
|
||||
// otherwise leave a completed test running after the editor is gone.
|
||||
if (app->window) KillTimer(app->window, 5);
|
||||
try
|
||||
{
|
||||
if (GetTickCount64() > deadline)
|
||||
@@ -142,10 +213,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);
|
||||
@@ -171,12 +244,20 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
"busy conversion attempt is logged");
|
||||
require(diagnostics.find("repeat=3") != std::string::npos,
|
||||
"three repeated conversion attempts are logged");
|
||||
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
|
||||
require(diagnostics.find("\"outcome\":\"recent-verification-mismatch\"") != std::string::npos,
|
||||
"failed recent-word verification has explicit outcome");
|
||||
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
|
||||
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
|
||||
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
|
||||
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
|
||||
"conversion diagnostics contain actual text and physical input for reproduction");
|
||||
}
|
||||
if (conversionOnly)
|
||||
{
|
||||
pasteBehavior = 1;
|
||||
prepare(L"ghbdtn", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
stage = 40;
|
||||
break;
|
||||
}
|
||||
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
||||
prepare(L"prefix ;addr", false);
|
||||
record(L";addr");
|
||||
@@ -234,6 +315,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
||||
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
prepare(L"ghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
@@ -245,6 +327,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
||||
nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
prepare(L"(ljvf ", false);
|
||||
record(L"(ljvf ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
@@ -280,7 +363,192 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 17:
|
||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||
[[fallthrough]];
|
||||
stage = 20;
|
||||
break;
|
||||
case 40:
|
||||
{
|
||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||||
"blocked paste records original text remaining in the actual editor");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||||
pasteBehavior = 2;
|
||||
prepare(L"ghbdtn", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
}
|
||||
case 41:
|
||||
{
|
||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||
"unexpected editor content records readback mismatch");
|
||||
pasteBehavior = 0;
|
||||
prepare(L"привет", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
}
|
||||
case 42:
|
||||
{
|
||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
|
||||
"manual unchanged conversion is recorded as a problem");
|
||||
diagnosticStart = diagnosticLog().size();
|
||||
app->settings.writeLog = false;
|
||||
ks::setLoggingEnabled(false);
|
||||
prepare(L"ghbdtn", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
}
|
||||
case 43:
|
||||
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
||||
"disabled diagnostics preserves conversion and writes no records");
|
||||
ks::setLoggingEnabled(true);
|
||||
prepare(L"PuntoSwitcher ", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 44:
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 45:
|
||||
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 46:
|
||||
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"suffix recovery restores original clipboard");
|
||||
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||
record(L"snipping ");
|
||||
droppedSelectionKeys = 4;
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 47:
|
||||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||
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");
|
||||
finish();
|
||||
break;
|
||||
}
|
||||
case 30:
|
||||
case 32:
|
||||
case 34:
|
||||
case 37:
|
||||
// Earlier password/word tests replace the original multiline
|
||||
// editor with a single-line EDIT. Restore the snippet fixture:
|
||||
// a single-line control cannot insert the tested Enter action.
|
||||
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
|
||||
{
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
|
||||
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
}
|
||||
if (!captured)
|
||||
{
|
||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||
captured = true;
|
||||
}
|
||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||
app->settings.autoCorrect = stage == 35;
|
||||
app->settings.autoLanguages = {L"en", L"ru"};
|
||||
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||
app->settings.switchLayout = false;
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
app->autoPredictor = ks::AutoCorrector{};
|
||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||||
enterPresses = 0;
|
||||
copyRequests = 0;
|
||||
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
|
||||
temporaryClipboardExcluded = true;
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"prefix ", false);
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
const std::vector<WORD> keys = stage == 35
|
||||
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
|
||||
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
|
||||
for (WORD key : keys)
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key, flags);
|
||||
input.ki.dwExtraInfo = 123;
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"snippet trigger and following text submitted without a pause");
|
||||
}
|
||||
break;
|
||||
case 31:
|
||||
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
|
||||
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
|
||||
stage = 32;
|
||||
break;
|
||||
case 33:
|
||||
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
||||
require(copyRequests == 2 && copyFailuresRemaining == 0,
|
||||
"snippet retries a silently dropped copy without duplicating input");
|
||||
require(temporaryClipboardExcluded,
|
||||
"temporary snippet clipboard contents are excluded from Windows history");
|
||||
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
||||
stage = 34;
|
||||
break;
|
||||
case 35:
|
||||
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
|
||||
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
|
||||
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
for (WORD key : std::wstring(L"GHBDTN "))
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||
input.ki.dwExtraInfo = 123;
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"normal typing submitted after snippet completes");
|
||||
}
|
||||
break;
|
||||
case 36:
|
||||
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
||||
stage = 37;
|
||||
break;
|
||||
case 38:
|
||||
require(copyRequests == 3, "copy retries are bounded to three attempts");
|
||||
require(text() == L"prefix ;r x" && enterPresses == 0,
|
||||
"exhausted copy retries preserve trigger and following input without Enter");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"exhausted copy retries restore the clipboard");
|
||||
copyFailuresRemaining = 0;
|
||||
finish();
|
||||
break;
|
||||
case 20:
|
||||
if (!captured)
|
||||
{
|
||||
@@ -315,7 +583,99 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case 19:
|
||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||
"rejected correction collapses selection before replaying typing in original layout");
|
||||
finish();
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"", false);
|
||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
for (WORD key : std::wstring(L"GHBDTN F"))
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||
input.ki.dwExtraInfo = 123;
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"continuous typing submitted in a single batch without a pause after Space");
|
||||
}
|
||||
stage = 21;
|
||||
break;
|
||||
case 21:
|
||||
{
|
||||
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
||||
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
|
||||
return ks::layoutLanguage(layout) == L"uk";
|
||||
});
|
||||
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
|
||||
app->settings.autoLanguages = {L"en", L"uk"};
|
||||
app->settings.cycle = {app->layouts[en].id, uk->id};
|
||||
app->settings.autoWords[L"uk"] = {L"робить"};
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"hj,bnm ", false);
|
||||
record(L"hj,bnm ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
stage = 22;
|
||||
break;
|
||||
}
|
||||
case 22:
|
||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -338,6 +698,8 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
} // namespace
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||||
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()));
|
||||
@@ -353,9 +715,22 @@ int main(int argc, char **argv)
|
||||
ks::showLogs(instance);
|
||||
HWND viewer = instance.logWindow;
|
||||
HWND rows = GetDlgItem(viewer, 602);
|
||||
if (argc > 2 && std::string(argv[2]) == "--preview")
|
||||
{
|
||||
MSG message{};
|
||||
while (IsWindow(viewer) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
{
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
||||
"log viewer uses report ListView");
|
||||
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
|
||||
require(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable columns");
|
||||
require(GetDlgItem(viewer, 607) && GetDlgItem(viewer, 608) && GetDlgItem(viewer, 609) &&
|
||||
GetDlgItem(viewer, 610) && GetDlgItem(viewer, 611),
|
||||
"log viewer exposes program, command, result, problems filter and detail panel");
|
||||
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
|
||||
SetFocus(rows);
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
||||
@@ -365,10 +740,11 @@ int main(int argc, char **argv)
|
||||
ks::showLogs(instance);
|
||||
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
|
||||
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
|
||||
require(IsWindow(viewer), "refresh keeps log viewer open");
|
||||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
||||
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
|
||||
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
|
||||
"previous log or explicit empty state is displayed");
|
||||
if (argc > 2 && std::string(argv[2]) == "--clear-test")
|
||||
{
|
||||
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
||||
@@ -393,7 +769,46 @@ int main(int argc, char **argv)
|
||||
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
||||
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
||||
"logging resumes after clear without stale context");
|
||||
ks::DiagnosticJson operation{{"attempt", "1"}, {"process", "alpha.exe"},
|
||||
{"command", "Convert / cycle"}, {"source", "English"}, {"target", "Ukrainian"},
|
||||
{"outcome", "Paste verified"}, {"problem", false}, {"original", "ghbdsn"},
|
||||
{"expected", ks::utf8(L"привіт")}, {"actual", ks::utf8(L"привіт")}};
|
||||
ks::clearDiagnosticLog(false);
|
||||
const auto successfulOperation = operation;
|
||||
for (int attempt = 0; attempt < 1000; ++attempt)
|
||||
ks::diagnosticOperation(operation.dump());
|
||||
require(!std::filesystem::exists(logRoot / L"kswitcher.log"),
|
||||
"one thousand successful operations create no log file");
|
||||
operation["attempt"] = "2"; operation["process"] = "beta.exe";
|
||||
operation["outcome"] = "Original text remained"; operation["problem"] = true;
|
||||
operation["actual"] = operation["original"];
|
||||
ks::diagnosticOperation(operation.dump());
|
||||
auto problems = ks::parseOperationLog(ks::readDiagnosticLog(false));
|
||||
require(problems.size() == 1 && problems[0].process == L"beta.exe" &&
|
||||
problems[0].details.find(L"Completed operation") != std::wstring::npos &&
|
||||
problems[0].details.find(L"Paste verified") != std::wstring::npos,
|
||||
"failure writes one row with bounded preceding successful context");
|
||||
// Pre-existing successful records remain viewable and filterable.
|
||||
ks::log(L"Operation " + ks::wide(successfulOperation.dump()));
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
||||
require(ListView_GetItemCount(rows) == 2, "one visible row per recorded operation");
|
||||
CheckDlgButton(viewer, 610, BST_CHECKED);
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(610, BN_CLICKED), 0);
|
||||
require(ListView_GetItemCount(rows) == 1, "only problems hides verified operations");
|
||||
SendDlgItemMessageW(viewer, 607, CB_SETCURSEL, 1, 0);
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(607, CBN_SELCHANGE), 0);
|
||||
require(ListView_GetItemCount(rows) == 0, "program filter combines with problems filter");
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(612, BN_CLICKED), 0);
|
||||
require(ListView_GetItemCount(rows) == 2, "reset restores all operation rows");
|
||||
NMLISTVIEW sort{}; sort.hdr = {rows, 602, LVN_COLUMNCLICK}; sort.iSubItem = 1;
|
||||
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&sort);
|
||||
wchar_t firstProgram[64]{};
|
||||
ListView_GetItemText(rows, 0, 1, firstProgram, 64);
|
||||
require(std::wstring(firstProgram) == L"alpha.exe", "clicking program column sorts rows");
|
||||
require(GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0, "selected operation populates detail panel");
|
||||
}
|
||||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
||||
auto savedClipboard = ks::readClipboard(viewer);
|
||||
instance.window = viewer;
|
||||
try
|
||||
@@ -439,8 +854,11 @@ int main(int argc, char **argv)
|
||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||
deadline = GetTickCount64() + 30000;
|
||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||
stage = 20;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||
stage = 30;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
app->execute();
|
||||
}
|
||||
|
||||
+374
-2
@@ -2,10 +2,69 @@
|
||||
// Including the implementation lets this integration test drive private editor state directly.
|
||||
#include "../src/screenshot.cpp"
|
||||
#include <iostream>
|
||||
int main()
|
||||
#include "stroke_improvement.h"
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
try {
|
||||
testStrokeImprovement();
|
||||
if(argc>1 && std::string(argv[1])=="--save-only") {
|
||||
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
|
||||
std::filesystem::create_directories(root);
|
||||
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
ks::Capture c(app);
|
||||
c.image=std::make_unique<ks::Surface>(320,240);
|
||||
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
|
||||
c.marks={ks::Mark{{40,40},{180,140},0}};
|
||||
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
|
||||
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
|
||||
auto path=root/L"Знімок.png";
|
||||
const auto clipboard=GetClipboardSequenceNumber();
|
||||
ks::saveToFile(nullptr,c,path);
|
||||
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
|
||||
auto actual=ks::decodeImage(encoded);
|
||||
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
|
||||
"PNG export preserves full resolution, annotations and opaque pixels");
|
||||
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
|
||||
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
|
||||
c.remember(c.marks); c.marks.clear();
|
||||
require(!c.copied,"editing after save marks changes unexported");
|
||||
ks::save(nullptr,c);
|
||||
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
|
||||
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
|
||||
c.copied=false;
|
||||
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
|
||||
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
|
||||
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
|
||||
return FALSE;
|
||||
});
|
||||
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
|
||||
auto another=root/L"Another.png";
|
||||
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
|
||||
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
|
||||
return TRUE;
|
||||
});
|
||||
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
|
||||
c.copied=false;
|
||||
bool failed=false;
|
||||
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
|
||||
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
|
||||
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
|
||||
"saving leaves no temporary files");
|
||||
bool invalid=false;
|
||||
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
|
||||
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
|
||||
});} catch(const std::exception &){invalid=true;}
|
||||
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
|
||||
CoUninitialize();
|
||||
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
|
||||
return 0;
|
||||
}
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||
@@ -24,6 +83,319 @@ int main()
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||
"new screenshot editor starts with the pencil tool");
|
||||
{
|
||||
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
|
||||
SendMessageW(window,WM_KEYDOWN,'G',0);
|
||||
require(c.shape==ks::ScreenshotShape::Triangle && c.arrow && !c.freehand,"G selects triangle drawing tool");
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{80,80}); mouse(WM_LBUTTONUP,{220,180});
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Triangle && c.marks[0].points.size()==4,
|
||||
"triangle tool creates a closed three-vertex shape");
|
||||
auto triangle=c.marks[0];
|
||||
for(double angle:{0.0,37.0,90.0,-125.0}) for(int i=0;i<3;++i) {
|
||||
auto original=triangle; original.rotation=angle;
|
||||
auto vertices=ks::shapeOutline(original);
|
||||
auto part=ks::MarkPart(int(ks::MarkPart::Vertex0)+i);
|
||||
require(ks::hitScreenshotMark(original,vertices[i],4)==part,"each rotated triangle vertex has its own handle");
|
||||
POINT target{vertices[i].x+13,vertices[i].y-9};
|
||||
auto changed=ks::dragScreenshotMark(original,part,vertices[i],target,320,240);
|
||||
auto moved=ks::shapeOutline(changed);
|
||||
for(int j=0;j<3;++j) {
|
||||
auto expected=j==i?target:vertices[j];
|
||||
require(std::abs(moved[j].x-expected.x)<=2 && std::abs(moved[j].y-expected.y)<=2,
|
||||
"dragging one vertex preserves both other world vertices after rotation");
|
||||
}
|
||||
require(changed.points.front().x==changed.points.back().x && changed.points.front().y==changed.points.back().y,
|
||||
"triangle remains closed after editing any vertex");
|
||||
}
|
||||
auto vertices=ks::shapeOutline(triangle);
|
||||
mouse(WM_LBUTTONDOWN,vertices[0]);
|
||||
require(c.part==ks::MarkPart::Vertex0,"mouse selects apex vertex");
|
||||
mouse(WM_LBUTTONUP,{vertices[0].x+20,vertices[0].y+10});
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x+20 && c.marks[0].points[1].x==triangle.points[1].x,
|
||||
"mouse vertex drag changes only requested corner");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x,"undo restores triangle vertex");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks[0].points[0].x==triangle.points[0].x+20,"redo reapplies triangle vertex edit");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
require(GetDlgItem(window,37)!=nullptr,"trapezoid has a dedicated toolbar button");
|
||||
SendMessageW(window,WM_KEYDOWN,'B',0);
|
||||
require(c.shape==ks::ScreenshotShape::Trapezoid && c.arrow && !c.freehand,"B selects trapezoid tool");
|
||||
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Trapezoid && c.marks[0].points.size()==5 && !c.marks[0].isHighlight(),
|
||||
"trapezoid tool creates a closed shape with unequal parallel bases");
|
||||
auto trapezoid=c.marks[0];
|
||||
require(trapezoid.points[0].y==trapezoid.points[1].y && trapezoid.points[2].y==trapezoid.points[3].y &&
|
||||
trapezoid.points[1].x-trapezoid.points[0].x<trapezoid.points[2].x-trapezoid.points[3].x,"toolbar trapezoid has parallel unequal bases");
|
||||
mouse(WM_LBUTTONDOWN,trapezoid.to); mouse(WM_LBUTTONUP,{260,200});
|
||||
require(c.marks[0].to.x==260 && c.marks[0].points[0].y==c.marks[0].points[1].y,"trapezoid resizes as a shape");
|
||||
auto grip=ks::screenshotShapeRotationHandle(c.marks[0]);
|
||||
auto rotated=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,grip,{280,135},320,240);
|
||||
require(rotated.rotation!=0 && rotated.shape==ks::ScreenshotShape::Trapezoid,"trapezoid supports shape rotation");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo trapezoid resize");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
||||
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
SendMessageW(window,WM_COMMAND,21,0);
|
||||
}
|
||||
{
|
||||
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle})
|
||||
for(bool recognized:{false,true}) {
|
||||
if(kind==ks::ScreenshotShape::Triangle && !recognized) continue;
|
||||
ks::Mark shape{{100,90},{200,170},0}; shape.shape=kind; shape.ends=ks::LineEnds::None;
|
||||
if(kind==ks::ScreenshotShape::Circle) shape.to.y=190;
|
||||
if(recognized) shape.points=kind==ks::ScreenshotShape::Triangle?
|
||||
std::vector<POINT>{{150,90},{200,170},{100,170},{150,90}}:ks::shapeOutline(shape);
|
||||
auto grip=ks::screenshotShapeRotationHandle(shape);
|
||||
POINT center{(shape.from.x+shape.to.x)/2,(shape.from.y+shape.to.y)/2};
|
||||
POINT right{center.x+center.y-grip.y,center.y};
|
||||
auto rotated=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,right,320,240);
|
||||
require(std::abs(rotated.rotation-90)<.01 && rotated.from.x==shape.from.x && rotated.to.y==shape.to.y,
|
||||
"all native and recognized shapes rotate around their center");
|
||||
auto corner=ks::rotateShapePoint(rotated,rotated.to);
|
||||
require(ks::hitScreenshotMark(rotated,corner,4)==(kind==ks::ScreenshotShape::Triangle?ks::MarkPart::Vertex1:ks::MarkPart::End),"rotated resize handles remain hittable");
|
||||
auto fixed=ks::rotateShapePoint(rotated,rotated.from);
|
||||
auto resized=ks::dragScreenshotMark(rotated,ks::MarkPart::End,corner,{corner.x-15,corner.y+20},320,240);
|
||||
auto stillFixed=ks::rotateShapePoint(resized,resized.from);
|
||||
require(std::abs(stillFixed.x-fixed.x)<=1 && std::abs(stillFixed.y-fixed.y)<=1 && resized.rotation==90,
|
||||
"resizing rotated shapes keeps the opposite world corner fixed");
|
||||
auto snapped=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,{center.x+60,center.y-20},320,240,true);
|
||||
require(std::abs(std::remainder(snapped.rotation,15))<.01,"Shift snaps shape rotation to 15 degrees");
|
||||
auto cloned=ks::cloneScreenshotMark(rotated,{rotated},320,240);
|
||||
require(cloned.rotation==rotated.rotation && cloned.shape==kind,"clone preserves shape rotation");
|
||||
auto boundary=ks::shapeOutline(shape).front();
|
||||
std::vector<ks::Mark> linked{shape,{{20,20},boundary,0}};
|
||||
ks::linkScreenshotEndpoint(linked[1],false,boundary,linked,4);
|
||||
require(linked[1].toLink.target!=0,"connector attaches to shape before rotation");
|
||||
linked[0].rotation=90; ks::resolveScreenshotLinks(linked);
|
||||
auto expected=ks::rotateShapePoint(linked[0],boundary);
|
||||
require(std::abs(linked[1].to.x-expected.x)<=1 && std::abs(linked[1].to.y-expected.y)<=1,
|
||||
"connector follows rotated shape boundary");
|
||||
}
|
||||
ks::Mark shape{{100,90},{200,170},0}; shape.shape=ks::ScreenshotShape::Rectangle;
|
||||
c.marks={shape}; c.selected=0;
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
auto x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
auto y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||
};
|
||||
auto grip=ks::screenshotShapeRotationHandle(shape,double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"shape rotation grip is distinct from thickness grip");
|
||||
mouse(WM_LBUTTONDOWN,grip); mouse(WM_LBUTTONUP,{150+130-grip.y,130});
|
||||
require(std::abs(c.marks[0].rotation-90)<1,"mouse rotation updates selected shape");
|
||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].rotation==0,"undo restores original shape orientation");
|
||||
SendMessageW(window,WM_COMMAND,18,0); require(std::abs(c.marks[0].rotation-90)<1,"redo restores shape rotation");
|
||||
c.selected=0;
|
||||
ks::Surface rotatedPixels(320,240); PatBlt(rotatedPixels.dc,0,0,320,240,WHITENESS);
|
||||
auto rendered=ks::decodeImage(ks::screenshotClip(rotatedPixels.dc,320,240,c.marks));
|
||||
auto pixel=(100*320+190)*4;
|
||||
require(rendered.bgra[pixel+2]==GetRValue(shape.color) && rendered.bgra[pixel+1]==GetGValue(shape.color),
|
||||
"export renders rotated shape at its new boundary");
|
||||
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||
ks::zoomCanvas(window,c,2,center);
|
||||
grip=ks::screenshotShapeRotationHandle(c.marks[0],double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"rotation grip works on zoomed canvas");
|
||||
ks::zoomCanvas(window,c,1,center,true);
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
}
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
auto fit=c.view;
|
||||
SendMessageW(window,WM_KEYDOWN,VK_OEM_PLUS,0);
|
||||
require(c.zoom==1.25 && c.view.right-c.view.left>fit.right-fit.left,"Ctrl+plus zooms canvas");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SUBTRACT,0);
|
||||
require(std::abs(c.zoom-1)<.001,"Ctrl+numpad minus zooms out");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_ADD,0);
|
||||
SendMessageW(window,WM_KEYDOWN,VK_OEM_MINUS,0);
|
||||
require(std::abs(c.zoom-1)<.001,"both keyboard plus/minus variants work");
|
||||
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||
ks::zoomCanvas(window,c,4,center);
|
||||
POINT anchor{center.x+12,center.y+8},screen=anchor; ClientToScreen(window,&screen);
|
||||
auto before=ks::imagePoint(c,anchor,false); double zoom=c.zoom;
|
||||
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||
require(c.zoom==zoom,"partial wheel delta waits for a full step");
|
||||
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||
auto after=ks::imagePoint(c,anchor,false);
|
||||
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
|
||||
"Ctrl+wheel zoom keeps image point under cursor");
|
||||
require(!c.history.canUndo(),"zoom does not add image edits to undo history");
|
||||
auto view=c.view;
|
||||
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
|
||||
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));
|
||||
SendMessageW(window,WM_MBUTTONUP,0,MAKELPARAM(center.x+15,center.y+10));
|
||||
require(c.view.left==view.left+15 && c.view.top==view.top+10 && !c.panning,"middle drag pans enlarged canvas");
|
||||
keys[VK_CONTROL]=0; SetKeyboardState(keys);
|
||||
auto a=ks::imagePoint(c,center),b=ks::imagePoint(c,{center.x+30,center.y+20});
|
||||
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(center.x,center.y));
|
||||
SendMessageW(window,WM_LBUTTONUP,0,MAKELPARAM(center.x+30,center.y+20));
|
||||
require(c.marks.size()==1 && c.marks[0].from.x==a.x && c.marks[0].to.x==b.x && c.marks[0].to.y==b.y,
|
||||
"drawing after zoom and pan uses image coordinates");
|
||||
auto original=c.marks[0];
|
||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
SendMessageW(window,WM_KEYDOWN,'0',0);
|
||||
require(c.zoom==1 && c.panX==0 && c.panY==0 && c.marks[0].from.x==original.from.x && c.marks[0].to.x==original.to.x,
|
||||
"Ctrl+0 resets view without transforming annotations");
|
||||
ks::zoomCanvas(window,c,1e6,center); require(c.zoom==16,"zoom has upper bound");
|
||||
ks::zoomCanvas(window,c,1e-6,center); require(c.zoom==.1,"zoom has lower bound");
|
||||
SendMessageW(window,WM_KEYDOWN,'0',0); SetKeyboardState(savedKeys);
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
}
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||
auto mouse=[&](UINT message,POINT p,bool shift=false) {
|
||||
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{30,190});
|
||||
for(int x=35;x<=200;x+=5) mouse(WM_MOUSEMOVE,{x,190+(x%10?2:-2)});
|
||||
auto original=c.stroke.points;
|
||||
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||
require(c.marks.size()==1,"improved stroke is one object");
|
||||
require(!c.marks[0].freehand && c.marks[0].points.empty() &&
|
||||
c.marks[0].shape==ks::ScreenshotShape::None && !c.marks[0].isHighlight(),
|
||||
"near-straight pencil stroke becomes an editable line on mouse release");
|
||||
require(c.marks[0].color==c.currentColor && c.marks[0].thickness==3 &&
|
||||
c.marks[0].style==c.lineStyle && c.marks[0].ends==c.freehandEnds,
|
||||
"line recognition preserves drawing style");
|
||||
auto improved=c.marks.back().points;
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.size()==1 && c.marks.back().freehand && c.marks.back().points.size()==original.size() &&
|
||||
std::equal(original.begin(),original.end(),c.marks.back().points.begin(),
|
||||
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;}),"first undo restores exact raw stroke");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.empty(),"second undo removes stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0); SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks.size()==1 && !c.marks.back().freehand && c.marks.back().points.size()==improved.size(),"redo restores improvement");
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
mouse(WM_LBUTTONDOWN,{180,120});
|
||||
for(int i=1;i<=120;++i) {
|
||||
double a=i*2*3.141592653589793/120;
|
||||
mouse(WM_MOUSEMOVE,{LONG(std::lround(130+50*std::cos(a))),LONG(std::lround(120+50*std::sin(a)))});
|
||||
}
|
||||
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Circle &&
|
||||
!c.marks[0].freehand && !c.marks[0].isHighlight(),"recognized circle is a shape, not a pencil or highlight");
|
||||
auto circle=c.marks[0];
|
||||
require(ks::hitScreenshotMark(circle,circle.to,6)==ks::MarkPart::End,"recognized shape exposes bounding-box resize handle");
|
||||
mouse(WM_LBUTTONDOWN,circle.to);
|
||||
mouse(WM_MOUSEMOVE,{circle.to.x+30,circle.to.y+20});
|
||||
mouse(WM_LBUTTONUP,{circle.to.x+30,circle.to.y+20});
|
||||
require(c.marks[0].to.x>circle.to.x && c.marks[0].to.x-c.marks[0].from.x==c.marks[0].to.y-c.marks[0].from.y &&
|
||||
c.marks[0].points[0].x>circle.points[0].x,"dragging recognized circle handle scales the whole circle proportionally");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].shape==ks::ScreenshotShape::Circle && c.marks[0].to.x==circle.to.x,"undo restores shape size");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks[0].freehand && c.marks[0].shape==ks::ScreenshotShape::None,"undo recognition restores editable pencil stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks[0].shape==ks::ScreenshotShape::Circle,"redo restores shape type");
|
||||
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle}) {
|
||||
auto shape=circle; shape.shape=kind;
|
||||
auto resized=ks::dragScreenshotMark(shape,ks::MarkPart::End,shape.to,{shape.to.x+50,shape.to.y+20},320,240);
|
||||
require(resized.points[0].x!=shape.points[0].x && resized.points[24].y!=shape.points[24].y,
|
||||
"shape resize transforms all outline points in both axes");
|
||||
auto moved=ks::cloneScreenshotMark(resized,{resized},320,240);
|
||||
require(moved.shape==kind && !moved.isHighlight(),"clone preserves recognized shape type");
|
||||
}
|
||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||
mouse(WM_MOUSEMOVE,{100,90},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
|
||||
"50 pixel deviation keeps the current axis");
|
||||
mouse(WM_MOUSEMOVE,{100,91},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
|
||||
"51 pixel deviation turns vertically and preserves the horizontal segment");
|
||||
mouse(WM_MOUSEMOVE,{70,140});
|
||||
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
|
||||
"releasing Shift resumes freehand in the same stroke");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||
mouse(WM_MOUSEMOVE,{140,150},true);
|
||||
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
|
||||
"pressing Shift mid-stroke anchors at the last drawn point");
|
||||
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||
mouse(WM_MOUSEMOVE,{150,200},true);
|
||||
mouse(WM_LBUTTONUP,{150,210},true);
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
|
||||
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
|
||||
c.marks.clear(); c.selected=-1;
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||
mouse(WM_MOUSEMOVE,{160+sx,120},true);
|
||||
for(int step=2;step<=90;++step)
|
||||
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
|
||||
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
|
||||
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
|
||||
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
|
||||
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
|
||||
for(int step=1;step<=90;++step)
|
||||
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
|
||||
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
|
||||
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
|
||||
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
|
||||
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
|
||||
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
|
||||
"Shift snaps to exact 45 degree diagonals in all four directions");
|
||||
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,40},true);
|
||||
mouse(WM_MOUSEMOVE,{160,100},true);
|
||||
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
|
||||
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
|
||||
"large deviation turns a horizontal segment diagonally without losing its corner");
|
||||
mouse(WM_MOUSEMOVE,{200,100},true);
|
||||
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
|
||||
"small perpendicular deviation keeps the diagonal axis");
|
||||
mouse(WM_MOUSEMOVE,{260,120},true);
|
||||
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
|
||||
"diagonal deviation beyond 50 pixels turns back to horizontal");
|
||||
mouse(WM_LBUTTONUP,{260,120},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
POINT start{sx>0?310L:9L,120};
|
||||
mouse(WM_LBUTTONDOWN,start,true);
|
||||
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
|
||||
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
|
||||
auto end=c.stroke.to;
|
||||
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
|
||||
std::abs(end.x-start.x)==std::abs(end.y-start.y),
|
||||
"diagonal stays at 45 degrees and inside image boundaries");
|
||||
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
mouse(WM_LBUTTONDOWN,{40,40});
|
||||
mouse(WM_MOUSEMOVE,{100,70});
|
||||
mouse(WM_LBUTTONUP,{120,90});
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
|
||||
c.marks[0].to.x==120 && c.marks[0].to.y==90,
|
||||
"Ctrl keeps its existing drawing behavior without constraining the pencil");
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
for(int size:{32,48,64,96}) {
|
||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||
ICONINFO info{};
|
||||
@@ -76,7 +448,7 @@ int main()
|
||||
}
|
||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
|
||||
require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
|
||||
RECT client{}; GetClientRect(window,&client);
|
||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||
ks::updateStatus(c);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "ui_scroll.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
int forwarded{};
|
||||
long long totalDelta{};
|
||||
LRESULT CALLBACK observe(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(msg==WM_MOUSEWHEEL) { ++forwarded; totalDelta+=GET_WHEEL_DELTA_WPARAM(wp); }
|
||||
return DefSubclassProc(w,msg,wp,lp);
|
||||
}
|
||||
void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(message); }
|
||||
void wheel(HWND w,int delta) { SendMessageW(w,WM_MOUSEWHEEL,MAKEWPARAM(0,(WORD)(short)delta),0); }
|
||||
void flush(HWND w) { SendMessageW(w,WM_TIMER,ks::ScrollFrameTimer,0); }
|
||||
}
|
||||
int main()
|
||||
{
|
||||
INITCOMMONCONTROLSEX init{sizeof(init),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&init);
|
||||
auto instance=GetModuleHandleW(nullptr);
|
||||
auto parent=CreateWindowW(L"STATIC",L"Scroll regression",WS_OVERLAPPEDWINDOW,0,0,500,400,nullptr,nullptr,instance,nullptr);
|
||||
try {
|
||||
auto list=CreateWindowW(L"LISTBOX",L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||
for(int i=0;i<2000;++i) SendMessageW(list,LB_ADDSTRING,0,(LPARAM)L"row");
|
||||
SendMessageW(list,LB_SETCURSEL,5,0);
|
||||
SendMessageW(list,LB_SETTOPINDEX,0,0);
|
||||
ks::enableWheelBatching(list,true);
|
||||
for(int i=0;i<1000;++i) wheel(list,-120);
|
||||
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"burst is deferred to one frame");
|
||||
flush(list);
|
||||
UINT lines{}; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"burst moves list");
|
||||
require(SendMessageW(list,LB_GETCURSEL,0,0)==5,"scroll does not change selection");
|
||||
for(int i=0;i<1000;++i) wheel(list,120);
|
||||
flush(list);
|
||||
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"reverse burst clamps at top");
|
||||
wheel(list,-60); flush(list);
|
||||
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"partial wheel step is retained");
|
||||
wheel(list,-60); flush(list);
|
||||
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"partial steps combine across frames");
|
||||
DestroyWindow(list);
|
||||
for(auto cls:{L"EDIT",WC_LISTVIEWW,L"COMBOBOX",L"STATIC"}) {
|
||||
auto w=CreateWindowW(cls,L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||
require(w!=nullptr,"native scroll control created");
|
||||
SetWindowSubclass(w,observe,1,0); ks::enableWheelBatching(w);
|
||||
forwarded=0; totalDelta=0;
|
||||
for(int i=0;i<100;++i) wheel(w,-120);
|
||||
require(forwarded==0,"native wheel burst is batched");
|
||||
flush(w);
|
||||
require(forwarded==1 && totalDelta==-12000,"one native dispatch preserves burst distance");
|
||||
wheel(w,60); flush(w);
|
||||
require(forwarded==2 && totalDelta==-11940,"native partial steps are preserved");
|
||||
forwarded=0; totalDelta=0;
|
||||
for(int i=0;i<400;++i) wheel(w,-120);
|
||||
flush(w); flush(w);
|
||||
require(forwarded==2 && totalDelta==-48000,"large native burst is not truncated to 16 bits");
|
||||
SendMessageW(w,WM_TIMER,ks::ScrollIdleTimer,0);
|
||||
DestroyWindow(w);
|
||||
}
|
||||
DestroyWindow(parent);
|
||||
std::cout<<"Passed shared wheel batching regressions (list, edit, table, combo, window)\n";
|
||||
return 0;
|
||||
} catch(const std::exception &e) {
|
||||
DestroyWindow(parent); std::cerr<<e.what()<<'\n'; return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#pragma once
|
||||
inline void testStrokeImprovement() {
|
||||
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||
for(double angle:{0.0,.37,1.57079632679,2.8}) for(bool reverse:{false,true}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=20;++i) {
|
||||
double x=i*10,y=i==0 || i==20?0:(i%3)-1;
|
||||
p.push_back({LONG(std::lround(300+x*std::cos(angle)-y*std::sin(angle))),
|
||||
LONG(std::lround(300+x*std::sin(angle)+y*std::cos(angle)))});
|
||||
}
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
bool line=false; ks::ScreenshotShape shape{};
|
||||
auto result=ks::improveScreenshotStroke(p,&shape,&line);
|
||||
require(line && shape==ks::ScreenshotShape::None && result.size()==2 &&
|
||||
result.front().x==p.front().x && result.front().y==p.front().y &&
|
||||
result.back().x==p.back().x && result.back().y==p.back().y,
|
||||
"recognize noisy lines at arbitrary angles and preserve directed endpoints");
|
||||
}
|
||||
for(auto p:std::vector<std::vector<POINT>>{
|
||||
{{0,0},{50,1},{100,0}}, {{0,0},{0,0},{50,0},{100,0}}}) {
|
||||
bool line=false;
|
||||
require(ks::improveScreenshotStroke(p,nullptr,&line).size()==2 && line,
|
||||
"recognize sparsely sampled lines and repeated mouse positions");
|
||||
}
|
||||
for(auto p:std::vector<std::vector<POINT>>{
|
||||
{{0,0},{8,1},{16,0}}, {{0,0},{50,0},{40,0},{80,0},{100,0}},
|
||||
{{0,0},{25,8},{50,12},{75,8},{100,0}},
|
||||
{{0,0},{50,0},{51,10},{52,0},{100,0}}, {{0,0},{0,0},{0,0}}}) {
|
||||
bool line=true;
|
||||
ks::improveScreenshotStroke(p,nullptr,&line);
|
||||
require(!line,"do not straighten short strokes, retracing, curves, spikes or stationary input");
|
||||
}
|
||||
auto path=[](std::vector<POINT> corners) {
|
||||
std::vector<POINT> p;
|
||||
for(size_t i=1;i<corners.size();++i) for(int j=0;j<25;++j) {
|
||||
double t=j/25.0;
|
||||
p.push_back({LONG(std::lround(corners[i-1].x*(1-t)+corners[i].x*t)),
|
||||
LONG(std::lround(corners[i-1].y*(1-t)+corners[i].y*t))});
|
||||
}
|
||||
p.push_back(corners.back()); return p;
|
||||
};
|
||||
for(bool tilted:{false,true}) {
|
||||
auto p=path({{40,40},{240,40},{240,140},{40,140},{40,40}});
|
||||
if(tilted) for(auto &q:p) {double x=q.x,y=q.y;q={LONG(std::lround(x*.866-y*.5+100)),LONG(std::lround(x*.5+y*.866))};}
|
||||
for(size_t i=1;i+1<p.size();++i) p[i].y+=int(i%3)-1;
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()==5,"recognize noisy and tilted rectangles");
|
||||
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||
double bx=result[2].x-result[1].x,by=result[2].y-result[1].y;
|
||||
require(std::abs(ax*bx+ay*by)<300,"rectangle has perpendicular sides");
|
||||
}
|
||||
auto triangle=ks::improveScreenshotStroke(path({{130,30},{240,180},{30,180},{130,30}}));
|
||||
for(auto corners:std::vector<std::vector<POINT>>{
|
||||
{{435,172},{455,135},{481,76},{514,78},{567,78},{605,76},{631,103},{652,135},{681,171},{609,172},{539,174},{435,172}},
|
||||
{{531,311},{553,280},{582,235},{616,236},{672,233},{686,264},{693,287},{710,318},{640,314},{531,311}},
|
||||
{{70,40},{160,40},{220,150},{20,150},{70,40}}})
|
||||
for(double angle:{0.0,.6,2.2}) for(bool reverse:{false,true}) {
|
||||
auto p=path(corners);
|
||||
for(auto &q:p) { double x=q.x,y=q.y; q={LONG(std::lround(x*std::cos(angle)-y*std::sin(angle)+700)),
|
||||
LONG(std::lround(x*std::sin(angle)+y*std::cos(angle)+700))}; }
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
ks::ScreenshotShape kind{}; auto result=ks::improveScreenshotStroke(p,&kind);
|
||||
require(kind==ks::ScreenshotShape::Trapezoid && result.size()==5,"recognize hand-drawn and tilted trapezoids");
|
||||
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||
double bx=result[3].x-result[2].x,by=result[3].y-result[2].y;
|
||||
require(std::abs(ax*by-ay*bx)/(std::hypot(ax,ay)*std::hypot(bx,by))<.025,"recognized trapezoid bases are parallel");
|
||||
}
|
||||
// Approximate the reported skewed square, including its small closing gap.
|
||||
auto unevenBox=path({{662,297},{663,213},{659,199},{659,181},{691,181},{718,189},
|
||||
{753,195},{786,195},{775,217},{771,249},{771,289},{766,313},
|
||||
{745,311},{716,304},{689,300},{671,300}});
|
||||
for(double factor:{.5,1.0,2.5}) for(bool reverse:{false,true}) {
|
||||
auto p=unevenBox;
|
||||
for(auto &q:p) {q.x=LONG(std::lround((q.x-650)*factor+50));q.y=LONG(std::lround((q.y-170)*factor+50));}
|
||||
if(reverse) std::reverse(p.begin(),p.end());
|
||||
ks::ScreenshotShape kind{};
|
||||
auto result=ks::improveScreenshotStroke(p,&kind);
|
||||
require(kind==ks::ScreenshotShape::Rectangle && result.size()==5,"uneven square must not become a circle");
|
||||
}
|
||||
require(triangle.size()==4,"recognize triangle");
|
||||
for(double ry:{50.0,90.0}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {double a=i*2*3.141592653589793/120;
|
||||
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+ry*std::sin(a)))});}
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()==97 && result.front().x==result.back().x && result.front().y==result.back().y,
|
||||
"recognize ellipse and circle as closed outlines");
|
||||
}
|
||||
auto open=path({{20,20},{200,20},{200,150},{60,150}});
|
||||
// Hand-traced approximations of the uneven loops in the reported screenshot.
|
||||
for(auto corners:std::vector<std::vector<POINT>>{
|
||||
{{424,408},{449,407},{474,418},{487,439},{488,461},{475,485},{453,488},{432,479},{412,458},{410,435},{416,418},{431,409}},
|
||||
{{352,474},{373,482},{389,501},{392,518},{378,529},{351,531},{331,519},{324,504},{326,486},{339,477},{352,474}},
|
||||
{{246,236},{221,242},{192,256},{166,271},{151,289},{154,303},{172,316},{195,315},{226,305},{246,291},{250,272},{252,252},{246,236}}}) {
|
||||
auto result=ks::improveScreenshotStroke(path(corners));
|
||||
require(result.size()==97,"recognize uneven hand-drawn loops from screenshot");
|
||||
}
|
||||
for(double size:{35.0,100.0,300.0}) for(double aspect:{.5,1.0}) for(int direction:{-1,1}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {
|
||||
double a=direction*(.3+i*5.85/120),r=1+.07*std::sin(3*a)+.035*std::cos(7*a);
|
||||
double x=size*r*std::cos(a),y=size*aspect*r*std::sin(a);
|
||||
p.push_back({LONG(std::lround(500+x*.866-y*.5)),LONG(std::lround(500+x*.5+y*.866))});
|
||||
}
|
||||
require(ks::improveScreenshotStroke(p).size()==97,"recognize wobbly tilted ovals with closing gaps at different sizes");
|
||||
}
|
||||
for(double turns:{.72,1.8}) {
|
||||
std::vector<POINT> p;
|
||||
for(int i=0;i<=120;++i) {double a=i*turns*2*3.141592653589793/120;
|
||||
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+70*std::sin(a)))});}
|
||||
auto result=ks::improveScreenshotStroke(p);
|
||||
require(result.size()!=97,"do not close broad arcs or repeated loops into ovals");
|
||||
}
|
||||
auto smoothed=ks::improveScreenshotStroke(open);
|
||||
require(smoothed.size()>5 && smoothed.front().x==20 && smoothed.back().x==60,"open outline stays open");
|
||||
auto scribble=path({{20,20},{200,150},{200,20},{20,150},{20,20}});
|
||||
require(ks::improveScreenshotStroke(scribble).size()>97,"crossed scribble is not a shape");
|
||||
require(ks::improveScreenshotStroke({{1,1},{1,1},{1,1},{1,1},{1,1}}).empty(),"stationary stroke unchanged");
|
||||
std::vector<POINT> jitter;
|
||||
for(int i=0;i<=100;++i) jitter.push_back({20+i*2,100+(i%2?2:-2)});
|
||||
auto smooth=ks::improveScreenshotStroke(jitter);
|
||||
double deviation=0; for(auto p:smooth) deviation+=std::abs(p.y-100);
|
||||
require(deviation/smooth.size()<1.5 && smooth.front().y==jitter.front().y && smooth.back().y==jitter.back().y,
|
||||
"smoothing reduces jitter and preserves endpoints");
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+171
-2
@@ -1,7 +1,9 @@
|
||||
#include "autocorrect.h"
|
||||
#include "history_preview.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "storage.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
@@ -21,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
|
||||
bool thrown = false;
|
||||
try
|
||||
{
|
||||
{
|
||||
std::map<std::wstring,int> previews;
|
||||
std::set<std::wstring> visible;
|
||||
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
||||
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
||||
ks::trimHistoryPreviews(previews,visible);
|
||||
require(previews.size()==32,"preview cache stays bounded");
|
||||
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
||||
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
||||
ks::trimHistoryPreviews(previews,visible);
|
||||
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
||||
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
||||
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
||||
}
|
||||
f();
|
||||
}
|
||||
catch (...)
|
||||
@@ -44,6 +60,34 @@ int main(int argc, char **argv)
|
||||
}
|
||||
try
|
||||
{
|
||||
{
|
||||
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
||||
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
||||
{"problem", true}, {"verification", "read-back-via-accessibility"},
|
||||
{"original", "hello\n\"quoted\""}, {"expected", ks::utf8(L"привіт")}, {"actual", "hello"},
|
||||
{"context", "copy\npaste"}};
|
||||
auto encoded = L"2026-10-31 12:00:00 Operation " + ks::wide(record.dump());
|
||||
auto rows = ks::parseOperationLog(encoded);
|
||||
require(rows.size() == 1 && rows[0].process == L"editor.exe" && rows[0].problem,
|
||||
"structured operation is one filterable row");
|
||||
require(rows[0].details.find(L"привіт") != std::wstring::npos &&
|
||||
rows[0].details.find(L"copy\r\npaste") != std::wstring::npos,
|
||||
"operation details preserve Unicode and normalize multiline text");
|
||||
record["problem"] = false;
|
||||
record["outcome"] = "Paste verified";
|
||||
rows = ks::parseOperationLog(encoded + L"\n2026-10-31 12:00:01 Operation " + ks::wide(record.dump()));
|
||||
require(rows.size() == 2 && !rows[1].problem, "verified operations remain distinguishable from problems");
|
||||
rows = ks::parseOperationLog(L"old incident\n first stage\n second stage\n2026-10-31 Operation {broken");
|
||||
require(rows.size() == 2 && rows[0].details.find(L"second stage") != std::wstring::npos &&
|
||||
rows[1].details.find(L"{broken") != std::wstring::npos,
|
||||
"legacy context is grouped and malformed records remain inspectable");
|
||||
require(ks::conversionReadbackOutcome(L"new", L"old", L"new") == L"Paste verified",
|
||||
"expected readback verifies paste");
|
||||
require(ks::conversionReadbackOutcome(L"old", L"old", L"new") == L"Original text remained",
|
||||
"original readback identifies unchanged editor text");
|
||||
require(ks::conversionReadbackOutcome(L"other", L"old", L"new") == L"Readback differs",
|
||||
"unexpected readback is never reported as success");
|
||||
}
|
||||
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
|
||||
"diagnostic word preserved");
|
||||
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
||||
@@ -67,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);
|
||||
@@ -148,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");
|
||||
@@ -247,6 +318,22 @@ 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);
|
||||
require(shared && shared->text == expected && shared->target == 2,
|
||||
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
|
||||
}
|
||||
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
|
||||
"different dictionary results remain ambiguous");
|
||||
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
||||
require(release && release->text == L"реліз " && release->target == 2,
|
||||
"supplemental Ukrainian release word corrects from Russian layout");
|
||||
@@ -255,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"релізик"};
|
||||
@@ -268,6 +357,15 @@ int main(int argc, char **argv)
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"never-correct list takes precedence over personal word");
|
||||
options.autoNever.clear();
|
||||
options.autoWords[L"uk"].push_back(L"робить");
|
||||
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
||||
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
||||
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
||||
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
||||
"punctuation-key correction still rejects digits");
|
||||
options.autoMinLength = 1;
|
||||
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
||||
"known English ye remains protected at minimum length one");
|
||||
options.cycle = {L"en", L"ru"};
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"personal dictionary respects disabled target layouts");
|
||||
@@ -276,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");
|
||||
@@ -284,6 +398,12 @@ int main(int argc, char **argv)
|
||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
||||
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
|
||||
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
|
||||
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
|
||||
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
|
||||
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
|
||||
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
|
||||
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
||||
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||
@@ -384,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}}}};
|
||||
@@ -429,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"(#";
|
||||
@@ -441,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");
|
||||
|
||||
@@ -10,6 +10,12 @@ try {
|
||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
||||
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
|
||||
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
|
||||
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
|
||||
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
|
||||
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
|
||||
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
|
||||
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
||||
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
||||
@@ -24,6 +30,8 @@ try {
|
||||
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
||||
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
||||
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
||||
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
|
||||
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
|
||||
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
||||
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
||||
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
||||
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
|
||||
Start-UpdateProgress
|
||||
try {
|
||||
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
|
||||
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
|
||||
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
|
||||
if ($Network) {
|
||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
|
||||
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
|
||||
}
|
||||
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
|
||||
Show-UpdateProgress $stage 'Update stage under test'
|
||||
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
|
||||
}
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
|
||||
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
|
||||
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
|
||||
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
|
||||
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
|
||||
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
|
||||
'Passed updater progress UI checks'
|
||||
} finally {
|
||||
$script:progressWindow.Dispose()
|
||||
$script:progressWindow = $null
|
||||
}
|
||||
@@ -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