Compare commits
22 Commits
release-v0.6.11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b51d341 | |||
| 0184b837f4 | |||
| ca553a5e7d | |||
| 55993c0f90 | |||
| 738c81dce2 | |||
| 22edece7cf | |||
| 908985d775 | |||
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 |
@@ -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)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.11 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.30 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
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_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
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)
|
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_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
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_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||||
add_executable(KSwitcherTests tests/tests.cpp)
|
add_executable(KSwitcherTests tests/tests.cpp)
|
||||||
|
add_executable(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)
|
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||||
@@ -39,7 +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)
|
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||||
@@ -49,14 +59,18 @@ else()
|
|||||||
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
target_link_options(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||||
|
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
|
||||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||||
|
|||||||
@@ -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 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.
|
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.
|
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.
|
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
|
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
|
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
|
**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
|
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
|
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||||
|
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
|
||||||
|
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
|
||||||
|
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
|
||||||
|
must be saved or copied again before closing without a warning.
|
||||||
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
||||||
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||||
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||||
@@ -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
|
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
|
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.
|
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
|
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
|
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.
|
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
|
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
|
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.
|
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
|
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||||
for shapes.
|
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:
|
**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
|
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
|
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
The window close button asks for confirmation before discarding changes that have not been saved or copied.
|
||||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||||
The shortcut is configurable in Settings under **Screen capture**.
|
The shortcut is configurable in Settings under **Screen capture**.
|
||||||
@@ -216,15 +259,31 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||||
|
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||||
|
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||||
|
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||||
|
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
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.
|
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.
|
- 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: with manual paste readback
|
||||||
|
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
|
||||||
|
original and fresh native queries confirm that exactly the original range is still selected.
|
||||||
|
Other controls and unknown/changed selections are not retried. A verified failed paste clears
|
||||||
|
repeat-conversion state and does not switch the Windows layout.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- 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;
|
- 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.
|
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
|
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||||
@@ -266,6 +325,16 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
|||||||
|
|
||||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||||
|
|
||||||
|
**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.
|
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||||
|
|
||||||
## Data and migration
|
## Data and migration
|
||||||
@@ -275,25 +344,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
|
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
|
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.
|
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.
|
Diagnostic stages stay in a memory buffer of at most 128 events for 30 seconds.
|
||||||
Only failures flush this context to `kswitcher.log`; flushed
|
Selection diagnostics include the expected text, each copied candidate before and after suffix
|
||||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
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,
|
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,
|
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
|
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
|
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
||||||
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
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
|
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.
|
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
||||||
An unchanged result containing no letters, or a direct command
|
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
||||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
checks the focused editor without moving the caret, changing selection, or copying again.
|
||||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
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.
|
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.
|
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,
|
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
|
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
||||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
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.
|
**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.
|
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||||
|
|
||||||
@@ -314,6 +403,119 @@ This is a working native implementation, not a claim of complete UI/release pari
|
|||||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
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.
|
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
|
## Build
|
||||||
|
|
||||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||||
@@ -333,13 +535,40 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
|
|||||||
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
||||||
verified local build uses GCC/w64devkit.
|
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.
|
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.
|
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.
|
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
|
**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
|
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
|
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
||||||
@@ -354,6 +583,8 @@ protect already valid input, and ambiguous alternatives remain unchanged regardl
|
|||||||
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
||||||
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
||||||
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
||||||
|
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
|
||||||
|
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
|
||||||
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||||
are eligible as correction targets at any frequency limit. The language and target layout must still
|
are eligible as correction targets at any frequency limit. The language and target layout must still
|
||||||
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
||||||
@@ -363,12 +594,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
|
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||||
files before replacing dictionaries with a new distribution.
|
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
|
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
|
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||||
pending work. During the replacement itself, key presses and releases are queued and replayed after
|
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.
|
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.
|
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
|
**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.
|
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
|
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||||
@@ -442,6 +676,13 @@ against a staged build. It briefly opens real selection/editor windows without c
|
|||||||
|
|
||||||
### Release channel rule
|
### 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
|
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`,
|
`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
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||||
|
|||||||
Binary file not shown.
+12
-10
@@ -1,21 +1,23 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.11 — 2026-10-29
|
## v0.6.30 — 2026-11-02
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Show an update progress window for manual checks and installation. Downloads display percentage and received/total size; verification, unpacking, installation and restart have distinct status messages.
|
- A manual paste confirmed unsuccessful by readback no longer records the expected replacement as a previous result or switches the Windows layout. This prevents repeated commands from searching for text that was never inserted.
|
||||||
- Keep the progress window running independently while KSwitcher exits and restarts. After the new application confirms startup, show the installed version and a Done button in the same window, without a separate completion notification.
|
- A previous-result verification mismatch clears stale repeat-conversion state.
|
||||||
- Show recovery and installation errors in the same window after the application exits. Background update checks remain quiet.
|
- A writable native Windows EDIT field may receive one guarded paste retry: readback must equal the original text, and fresh native checks must confirm the exact original selection, unchanged focus and input context. Other controls, protected/read-only fields and unknown/changed selections are not retried.
|
||||||
|
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
|
||||||
The progress UI is provided by the currently installed updater. Updating from v0.6.10 to this release still uses the older interface; subsequent updates use the new window.
|
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
214 native regression checks, 466 input/UI checks, screenshot editor UI checks, isolated notification registration, 45 updater regression checks, and progress-window checks for download, installation, recovery and completion states. Network tests verify real download progress and release download/checksum validation. Startup acknowledgement tests prevent reporting success for ordinary startup or a missing updater handshake.
|
The final isolated conversion integration run passed: permanently blocked paste, unexpected editor content, clearing fictional previous results, ignored-first-paste recovery, clipboard restoration, repeat conversion and partial-selection recovery. Typo integration also passed, including Undo and uninterrupted typing. 541 input/UI checks (240 DPI), 105 sync/protocol checks, 483 native regressions and 48 updater checks passed. All release executable versions and manifest hashes were verified. Earlier interactive attempts were interrupted by concurrent input or a transient clipboard-restoration assertion; the final isolated rerun passed.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
The cause of every ignored paste is not established. This release does not claim to solve all asynchronous selection failures in ChatGPT/browser editors. Keep Write log enabled and send complete operation details if the issue recurs. Native retry is bounded to one attempt; it is not a general retry policy for other applications. All existing dictionary-correction and synchronization limitations remain.
|
||||||
|
|
||||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package 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:
|
Build and package:
|
||||||
|
|
||||||
@@ -24,4 +26,4 @@ Build and package:
|
|||||||
.\package.ps1 -PublishRelease
|
.\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 @@
|
|||||||
d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip
|
7e557851ab68a37a8211ea39de41e79314f460ef8ac6471be723d0cfe03cfba9 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ $outDir = Join-Path $root 'build'
|
|||||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||||
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
||||||
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||||
|
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||||
|
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
try {
|
||||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||||
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$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','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
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"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
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
|
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
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' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
$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' }
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||||
@@ -39,11 +41,19 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
& $compiler @common tests/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' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
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' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
@@ -51,6 +61,8 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||||
& .\build\KSwitcherScreenshotUITests.exe
|
& .\build\KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||||
|
& .\build\KSwitcherScreenshotUITests.exe --save-only
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||||
try {
|
try {
|
||||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||||
@@ -65,13 +77,13 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||||
}
|
}
|
||||||
if ($Integration) {
|
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' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
}
|
}
|
||||||
if ($Ocr) {
|
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' }
|
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||||
& .\build\KSwitcherOcrTests.exe
|
& .\build\KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
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
|
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||||
|
|
||||||
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
|
Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
|
||||||
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
||||||
They are included with the default unlimited rank setting and recorded in sources.json.
|
They are included with the default unlimited rank setting and recorded in sources.json.
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
"uk": {
|
"uk": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||||
"words": 42550,
|
"words": 42551,
|
||||||
"bytes": 898227,
|
"bytes": 898248,
|
||||||
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
|
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
|
||||||
"supplementalWords": {
|
"supplementalWords": {
|
||||||
"\u0440\u0435\u043b\u0456\u0437": 50001
|
"\u0440\u0435\u043b\u0456\u0437": 50001,
|
||||||
|
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru": {
|
"ru": {
|
||||||
|
|||||||
@@ -27337,6 +27337,7 @@
|
|||||||
поторопись 9062
|
поторопись 9062
|
||||||
поторопитесь 20656
|
поторопитесь 20656
|
||||||
поторопиться 15390
|
поторопиться 15390
|
||||||
|
поточні 50001
|
||||||
потрапив 4168
|
потрапив 4168
|
||||||
потрапила 7383
|
потрапила 7383
|
||||||
потрапили 6631
|
потрапили 6631
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,11,0
|
FILEVERSION 0,6,30,0
|
||||||
PRODUCTVERSION 0,6,11,0
|
PRODUCTVERSION 0,6,30,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.11"
|
VALUE "FileVersion", "0.6.30"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+386
-79
@@ -1,7 +1,10 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "../vendor/json.hpp"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
|
#include "paste_probe.h"
|
||||||
#include "screenshot_notifications.h"
|
#include "screenshot_notifications.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -9,9 +12,21 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
#include "app_sync.inl"
|
||||||
App *App::current = nullptr;
|
App *App::current = nullptr;
|
||||||
namespace
|
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>> shortcuts(const Settings &s)
|
||||||
{
|
{
|
||||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||||
@@ -58,6 +73,8 @@ class Transaction
|
|||||||
Clip snapshot;
|
Clip snapshot;
|
||||||
DWORD expected{};
|
DWORD expected{};
|
||||||
bool captured{};
|
bool captured{};
|
||||||
|
mutable bool invalidLogged{};
|
||||||
|
unsigned copyNumber{};
|
||||||
std::wstring adopted;
|
std::wstring adopted;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -93,8 +110,18 @@ class Transaction
|
|||||||
}
|
}
|
||||||
bool valid() const
|
bool valid() const
|
||||||
{
|
{
|
||||||
return target && IsWindow(target) && GetForegroundWindow() == target &&
|
auto foreground = GetForegroundWindow();
|
||||||
app.inputVersion.load() == version;
|
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()
|
void check()
|
||||||
{
|
{
|
||||||
@@ -111,7 +138,13 @@ class Transaction
|
|||||||
{
|
{
|
||||||
check();
|
check();
|
||||||
adopted.clear();
|
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.
|
// 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.
|
// Never adopt that writer's sequence as our own and paste its content.
|
||||||
if (GetClipboardOwner() != app.window)
|
if (GetClipboardOwner() != app.window)
|
||||||
@@ -123,16 +156,22 @@ class Transaction
|
|||||||
}
|
}
|
||||||
std::wstring copy()
|
std::wstring copy()
|
||||||
{
|
{
|
||||||
|
const auto started = GetTickCount64();
|
||||||
|
const auto number = ++copyNumber;
|
||||||
if (!valid())
|
if (!valid())
|
||||||
return {};
|
return {};
|
||||||
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
||||||
DWORD before = expected;
|
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))
|
if (!valid() || !chord(L'C', MOD_CONTROL))
|
||||||
{
|
{
|
||||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
ULONGLONG deadline = GetTickCount64() + 2000;
|
ULONGLONG deadline = GetTickCount64() + 2000;
|
||||||
|
ULONGLONG retryAt = GetTickCount64() + 250;
|
||||||
|
unsigned attempts = 1;
|
||||||
DWORD changed = before;
|
DWORD changed = before;
|
||||||
ULONGLONG stable = 0;
|
ULONGLONG stable = 0;
|
||||||
bool changedAgain = false;
|
bool changedAgain = false;
|
||||||
@@ -143,16 +182,40 @@ class Transaction
|
|||||||
DWORD now = GetClipboardSequenceNumber();
|
DWORD now = GetClipboardSequenceNumber();
|
||||||
if (now != changed)
|
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;
|
changedAgain |= changed != before;
|
||||||
changed = now;
|
changed = now;
|
||||||
stable = GetTickCount64();
|
stable = GetTickCount64();
|
||||||
}
|
}
|
||||||
if (changed != before && GetTickCount64() - stable >= 60)
|
if (changed != before && GetTickCount64() - stable >= 60)
|
||||||
break;
|
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);
|
Sleep(10);
|
||||||
}
|
}
|
||||||
if (changed == before || !valid())
|
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)" :
|
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
||||||
L"Copy failed: context changed");
|
L"Copy failed: context changed");
|
||||||
return {};
|
return {};
|
||||||
@@ -163,6 +226,9 @@ class Transaction
|
|||||||
expected = changed;
|
expected = changed;
|
||||||
auto clip = readClipboard(app.window);
|
auto clip = readClipboard(app.window);
|
||||||
auto text = clip.text();
|
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;
|
adopted = text;
|
||||||
check();
|
check();
|
||||||
for (auto &f : clip.formats)
|
for (auto &f : clip.formats)
|
||||||
@@ -191,9 +257,31 @@ class Transaction
|
|||||||
return selectPrevious(n, [&] { return valid(); });
|
return selectPrevious(n, [&] { return valid(); });
|
||||||
}
|
}
|
||||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||||
const std::function<bool(const std::wstring &)> &matches)
|
const std::function<bool(const std::wstring &)> &matches,
|
||||||
|
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||||
{
|
{
|
||||||
// WebView word boundaries are only candidates; exact verification is mandatory.
|
// WebView word boundaries are only candidates; exact verification is mandatory.
|
||||||
|
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() &&
|
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||||
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
||||||
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
||||||
@@ -202,6 +290,7 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
auto text = copy();
|
auto text = copy();
|
||||||
|
diagnosticEvent(L"Selection word-boundary match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
|
||||||
if (valid() && matches(text))
|
if (valid() && matches(text))
|
||||||
{
|
{
|
||||||
diagnosticEvent(L"Selection: verified word boundary");
|
diagnosticEvent(L"Selection: verified word boundary");
|
||||||
@@ -211,7 +300,27 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
Sleep(25);
|
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)
|
bool paste(const Clip &clip)
|
||||||
{
|
{
|
||||||
@@ -229,6 +338,32 @@ class Transaction
|
|||||||
Sleep(300);
|
Sleep(300);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
bool nativeSelectionIs(const std::wstring &original) const
|
||||||
|
{
|
||||||
|
if (!valid()) return false;
|
||||||
|
GUITHREADINFO gui{sizeof(gui)};
|
||||||
|
if (!GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui) || !gui.hwndFocus) return false;
|
||||||
|
wchar_t cls[32]{};
|
||||||
|
GetClassNameW(gui.hwndFocus, cls, 32);
|
||||||
|
if (_wcsicmp(cls, L"Edit") != 0 ||
|
||||||
|
(GetWindowLongPtrW(gui.hwndFocus, GWL_STYLE) & (ES_PASSWORD | ES_READONLY))) return false;
|
||||||
|
auto query = [&](UINT message, WPARAM wp, LPARAM lp, DWORD_PTR &result)
|
||||||
|
{
|
||||||
|
return SendMessageTimeoutW(gui.hwndFocus, message, wp, lp,
|
||||||
|
SMTO_ABORTIFHUNG | SMTO_BLOCK, 100, &result) != 0;
|
||||||
|
};
|
||||||
|
DWORD_PTR range{}, length{}, copied{};
|
||||||
|
if (!query(EM_GETSEL, 0, 0, range) || (DWORD)range == 0xffffffff ||
|
||||||
|
size_t(HIWORD(range) - LOWORD(range)) != original.size() ||
|
||||||
|
!query(WM_GETTEXTLENGTH, 0, 0, length) || length > 65534 || HIWORD(range) > length) return false;
|
||||||
|
std::wstring content(length + 1, L'\0');
|
||||||
|
if (!query(WM_GETTEXT, content.size(), (LPARAM)content.data(), copied) || copied != length) return false;
|
||||||
|
DWORD_PTR after{};
|
||||||
|
GUITHREADINFO focused{sizeof(focused)};
|
||||||
|
return query(EM_GETSEL, 0, 0, after) && after == range && valid() &&
|
||||||
|
GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &focused) && focused.hwndFocus == gui.hwndFocus &&
|
||||||
|
content.substr(LOWORD(range), original.size()) == original;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||||
@@ -248,6 +383,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||||
}
|
}
|
||||||
setLoggingEnabled(settings.writeLog);
|
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
|
try
|
||||||
{
|
{
|
||||||
history = std::make_unique<History>(data / L"history");
|
history = std::make_unique<History>(data / L"history");
|
||||||
@@ -260,6 +402,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
if (updateWorker.joinable())
|
if (updateWorker.joinable())
|
||||||
updateWorker.join();
|
updateWorker.join();
|
||||||
if (keyboardHook)
|
if (keyboardHook)
|
||||||
@@ -390,7 +533,7 @@ void App::addTray()
|
|||||||
icon.uCallbackMessage = TrayMessage;
|
icon.uCallbackMessage = TrayMessage;
|
||||||
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||||
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||||
wcscpy_s(icon.szTip, L"KSwitcher — keyboard layouts & clipboard");
|
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
||||||
Shell_NotifyIconW(NIM_ADD, &icon);
|
Shell_NotifyIconW(NIM_ADD, &icon);
|
||||||
icon.uVersion = NOTIFYICON_VERSION_4;
|
icon.uVersion = NOTIFYICON_VERSION_4;
|
||||||
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
||||||
@@ -414,15 +557,23 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
return true;
|
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;
|
bool autostartChanged = false;
|
||||||
|
AutoCorrector predictor;
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
validateTesseract(s);
|
validateTesseract(s);
|
||||||
if (s.autoCorrect)
|
if (s.autoCorrect)
|
||||||
{
|
{
|
||||||
AutoCorrector probe;
|
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
|
||||||
}
|
}
|
||||||
if (!registerHotkeys(s))
|
if (!registerHotkeys(s))
|
||||||
{
|
{
|
||||||
@@ -454,14 +605,17 @@ void App::applySettings(const Settings &s)
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
settings = s;
|
settings = s;
|
||||||
|
autoPredictor = std::move(predictor);
|
||||||
setLoggingEnabled(s.writeLog);
|
setLoggingEnabled(s.writeLog);
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
++inputVersion;
|
++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.
|
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
scheduleHistoryOcr(s);
|
scheduleHistoryOcr(s);
|
||||||
|
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
}
|
}
|
||||||
@@ -485,6 +639,7 @@ void App::reloadLayouts()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
|
if (busy) diagnosticEvent(L"Input context changed: layouts reloaded version=" + std::to_wstring(inputVersion.load()));
|
||||||
}
|
}
|
||||||
registerHotkeys(settings);
|
registerHotkeys(settings);
|
||||||
}
|
}
|
||||||
@@ -514,9 +669,11 @@ void App::resetInput()
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
|
if (busy) diagnosticEvent(L"Input context changed: reset version=" + std::to_wstring(inputVersion.load()));
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
|
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||||
{
|
{
|
||||||
if (ownKeyboardInput(k))
|
if (ownKeyboardInput(k))
|
||||||
@@ -530,6 +687,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
if (isHotkey(vk, mods))
|
if (isHotkey(vk, mods))
|
||||||
return;
|
return;
|
||||||
++inputVersion;
|
++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();
|
last.reset();
|
||||||
HWND fg = GetForegroundWindow();
|
HWND fg = GetForegroundWindow();
|
||||||
HKL layout = windowLayout(fg);
|
HKL layout = windowLayout(fg);
|
||||||
@@ -557,9 +717,33 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.record(ch, fg, layout, GetTickCount64());
|
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)
|
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())
|
if (busy || settings.snippets.empty())
|
||||||
return;
|
return;
|
||||||
auto keys = lower(run.keys);
|
auto keys = lower(run.keys);
|
||||||
@@ -587,7 +771,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
for (auto &trigger : variants)
|
for (auto &trigger : variants)
|
||||||
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,12 +856,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
return true;
|
return true;
|
||||||
return false;
|
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);
|
std::lock_guard lock(stateMutex);
|
||||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||||
return false;
|
return false;
|
||||||
typingTarget = target;
|
typingTarget = target;
|
||||||
|
snippetTyping = snippet;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
void App::replayTyping()
|
void App::replayTyping()
|
||||||
@@ -677,7 +873,13 @@ void App::replayTyping()
|
|||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
target = typingTarget;
|
target = typingTarget;
|
||||||
typingTarget = nullptr;
|
typingTarget = nullptr;
|
||||||
|
autoPending = false;
|
||||||
|
snippetPending = -1;
|
||||||
inputs.swap(deferredTyping);
|
inputs.swap(deferredTyping);
|
||||||
|
if (snippetTyping)
|
||||||
|
for (auto &input : inputs)
|
||||||
|
input.ki.dwExtraInfo = snippetReplayMarker();
|
||||||
|
snippetTyping = false;
|
||||||
}
|
}
|
||||||
busy = false;
|
busy = false;
|
||||||
if (inputs.empty())
|
if (inputs.empty())
|
||||||
@@ -721,8 +923,11 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
|||||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||||
// range just like local clicks, including while a transaction is pending.
|
// 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 (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();
|
current->resetInput();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return CallNextHookEx(nullptr, code, wp, lp);
|
return CallNextHookEx(nullptr, code, wp, lp);
|
||||||
}
|
}
|
||||||
void App::dispatch(int command)
|
void App::dispatch(int command)
|
||||||
@@ -793,12 +998,30 @@ void App::transform(int command, int snippet)
|
|||||||
L" version=" + std::to_wstring(currentVersion) +
|
L" version=" + std::to_wstring(currentVersion) +
|
||||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
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)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
if (preDeferred)
|
||||||
|
{
|
||||||
|
autoPending = false;
|
||||||
|
snippetPending = -1;
|
||||||
|
}
|
||||||
Run recent;
|
Run recent;
|
||||||
std::optional<LastTransform> previous;
|
std::optional<LastTransform> previous;
|
||||||
Settings config;
|
Settings config;
|
||||||
@@ -813,7 +1036,7 @@ void App::transform(int command, int snippet)
|
|||||||
version = inputVersion;
|
version = inputVersion;
|
||||||
}
|
}
|
||||||
enqueue(
|
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
|
struct BusyReset
|
||||||
{
|
{
|
||||||
@@ -827,10 +1050,10 @@ void App::transform(int command, int snippet)
|
|||||||
else
|
else
|
||||||
b = false;
|
b = false;
|
||||||
}
|
}
|
||||||
} reset{busy, window};
|
} reset{busy, window, preDeferred};
|
||||||
auto start = GetTickCount64();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
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;
|
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||||
if (manual)
|
if (manual)
|
||||||
{
|
{
|
||||||
@@ -855,21 +1078,35 @@ void App::transform(int command, int snippet)
|
|||||||
bool enabled;
|
bool enabled;
|
||||||
std::wstring &reason;
|
std::wstring &reason;
|
||||||
std::wstring &text, &result, &expected;
|
std::wstring &text, &result, &expected;
|
||||||
|
std::wstring &actual, &verification;
|
||||||
int &hint, &to;
|
int &hint, &to;
|
||||||
|
int command;
|
||||||
|
const std::vector<Layout> &layouts;
|
||||||
~Audit()
|
~Audit()
|
||||||
{
|
{
|
||||||
if (enabled)
|
if (enabled)
|
||||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
|
try
|
||||||
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
|
{
|
||||||
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
|
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
|
||||||
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
|
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
|
||||||
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
|
||||||
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
|
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||||
L" result=" + diagnosticText(result),
|
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
|
||||||
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
||||||
reason != L"unchanged-expected");
|
{"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
|
try
|
||||||
{
|
{
|
||||||
auto valid = [&]
|
auto valid = [&]
|
||||||
@@ -905,14 +1142,15 @@ void App::transform(int command, int snippet)
|
|||||||
std::wstring predicted;
|
std::wstring predicted;
|
||||||
for (wchar_t key : recent.keys)
|
for (wchar_t key : recent.keys)
|
||||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||||
|
expected = predicted;
|
||||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||||
!autoCorrectionAllowed(target, config) || !valid())
|
!autoCorrectionAllowed(target, config) || !valid())
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reason = L"clipboard-snapshot";
|
reason = L"clipboard-snapshot";
|
||||||
if (command == AutoCorrect)
|
if (command == AutoCorrect || snippet >= 0)
|
||||||
{
|
{
|
||||||
if (!beginTypingDeferral(target, version))
|
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||||
return;
|
return;
|
||||||
reset.deferred = true;
|
reset.deferred = true;
|
||||||
}
|
}
|
||||||
@@ -944,9 +1182,12 @@ void App::transform(int command, int snippet)
|
|||||||
autoSelected = true;
|
autoSelected = true;
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
// Some editors first select only ") ". Extend by whole-word
|
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||||
// boundaries, then still verify against the complete tracked run.
|
// suffix of the tracked input, and still require a complete match.
|
||||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
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))
|
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||||
break;
|
break;
|
||||||
@@ -982,6 +1223,8 @@ void App::transform(int command, int snippet)
|
|||||||
if (newlines(text) != newlines(previous->result))
|
if (newlines(text) != newlines(previous->result))
|
||||||
{
|
{
|
||||||
reason = L"undo-verification-mismatch";
|
reason = L"undo-verification-mismatch";
|
||||||
|
actual = text;
|
||||||
|
verification = L"selection-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -1004,13 +1247,11 @@ void App::transform(int command, int snippet)
|
|||||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||||
if (!tx.select(sn.trigger.size()))
|
if (!tx.select(sn.trigger.size()))
|
||||||
return;
|
return;
|
||||||
|
autoSelected = true;
|
||||||
|
reason = L"snippet-verification-mismatch";
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
if (tail.match(text, available, physicalLayout()) < 0)
|
if (tail.match(text, available, physicalLayout()) < 0)
|
||||||
{
|
|
||||||
if (tx.valid())
|
|
||||||
chord(VK_RIGHT);
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
result = parseTemplate(sn.text).text;
|
result = parseTemplate(sn.text).text;
|
||||||
automatic = true;
|
automatic = true;
|
||||||
}
|
}
|
||||||
@@ -1018,12 +1259,16 @@ void App::transform(int command, int snippet)
|
|||||||
{
|
{
|
||||||
text =
|
text =
|
||||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
|
||||||
|
[&](const std::wstring &candidate)
|
||||||
|
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
||||||
hint = recent.match(text, available, physicalLayout());
|
hint = recent.match(text, available, physicalLayout());
|
||||||
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||||
if (hint < 0)
|
if (hint < 0)
|
||||||
{
|
{
|
||||||
|
actual = text;
|
||||||
|
verification = L"selection-mismatch";
|
||||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
@@ -1035,12 +1280,20 @@ void App::transform(int command, int snippet)
|
|||||||
previous->version == version)
|
previous->version == version)
|
||||||
{
|
{
|
||||||
expected = previous->result;
|
expected = previous->result;
|
||||||
|
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
|
||||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||||
[&](const std::wstring &candidate)
|
[&](const std::wstring &candidate)
|
||||||
{ return newlines(candidate) == newlines(previous->result); });
|
{ return newlines(candidate) == newlines(previous->result); });
|
||||||
if (newlines(text) != newlines(previous->result))
|
if (newlines(text) != newlines(previous->result))
|
||||||
{
|
{
|
||||||
|
actual = text;
|
||||||
|
verification = L"selection-mismatch";
|
||||||
reason = L"previous-result-verification-mismatch";
|
reason = L"previous-result-verification-mismatch";
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
}
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -1082,16 +1335,13 @@ void App::transform(int command, int snippet)
|
|||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int source = detect(text, available, hint);
|
to = nextConversionLayout(text, available, cycle, hint);
|
||||||
if (source < 0)
|
|
||||||
source = hint;
|
|
||||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
|
||||||
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
|
|
||||||
}
|
}
|
||||||
result = convertTo(text, available, to, hint);
|
result = convertTo(text, available, to, hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool sent = false;
|
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) +
|
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||||
L" changed=" + std::to_wstring(result != text));
|
L" changed=" + std::to_wstring(result != text));
|
||||||
if (result != text)
|
if (result != text)
|
||||||
@@ -1102,10 +1352,46 @@ void App::transform(int command, int snippet)
|
|||||||
autoSelected = false;
|
autoSelected = false;
|
||||||
reason = result == text ? L"conversion-unchanged" :
|
reason = result == text ? L"conversion-unchanged" :
|
||||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||||
if (result == text &&
|
if (manual && result == text)
|
||||||
(std::none_of(text.begin(), text.end(), letter) ||
|
{
|
||||||
(command >= Direct && detect(text, available, hint) == to)))
|
reason = L"Conversion unchanged";
|
||||||
reason = L"unchanged-expected";
|
verification = L"calculated-result-equals-original";
|
||||||
|
}
|
||||||
|
if (sent && manual && config.writeLog)
|
||||||
|
{
|
||||||
|
verification = probe.status;
|
||||||
|
if (tx.valid())
|
||||||
|
{
|
||||||
|
auto observed = probe.read((int)caretSteps(result));
|
||||||
|
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||||
|
// Retry once only with fresh native proof that the exact original range
|
||||||
|
// is still selected. Never retry an unknown or changed editor state.
|
||||||
|
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
||||||
|
if (tx.paste(textClip(result)) && tx.valid())
|
||||||
|
observed = probe.read((int)caretSteps(result));
|
||||||
|
else
|
||||||
|
observed.reset();
|
||||||
|
}
|
||||||
|
verification = probe.status;
|
||||||
|
if (observed && tx.valid())
|
||||||
|
{
|
||||||
|
actual = *observed;
|
||||||
|
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
||||||
|
if (newlines(actual) != newlines(result))
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
diagnosticEvent(L"Paste rejected by readback: previous result cleared; layout not switched");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else reason = L"Paste not verified";
|
||||||
|
}
|
||||||
|
else verification = L"context-changed-after-paste";
|
||||||
|
}
|
||||||
if (sent && !tx.valid())
|
if (sent && !tx.valid())
|
||||||
reason = L"context-changed-after-paste";
|
reason = L"context-changed-after-paste";
|
||||||
if (sent && command == Undo && previous && previous->autoCorrected)
|
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||||
@@ -1123,12 +1409,22 @@ void App::transform(int command, int snippet)
|
|||||||
if (snippet >= 0 && sent && tx.valid())
|
if (snippet >= 0 && sent && tx.valid())
|
||||||
{
|
{
|
||||||
auto t = parseTemplate(config.snippets[snippet].text);
|
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();
|
auto suffix = tx.copy();
|
||||||
if (tx.valid())
|
if (!tx.valid()) return;
|
||||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
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))
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||||
{
|
{
|
||||||
@@ -1275,28 +1571,28 @@ void App::recognizeEntry(std::wstring id)
|
|||||||
void App::trayMenu()
|
void App::trayMenu()
|
||||||
{
|
{
|
||||||
HMENU menu = CreatePopupMenu();
|
HMENU menu = CreatePopupMenu();
|
||||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||||
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
|
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||||
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
|
||||||
SetMenuDefaultItem(menu, 11, FALSE);
|
SetMenuDefaultItem(menu, 11, FALSE);
|
||||||
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||||
AppendMenuW(menu, MF_STRING, 13, L"Keyboard shortcuts…");
|
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
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();
|
HMENU pause = CreatePopupMenu();
|
||||||
AppendMenuW(pause, MF_STRING, 20, L"Resume");
|
AppendMenuW(pause, MF_STRING, 20, L"&Resume");
|
||||||
AppendMenuW(pause, MF_STRING, 21, L"10 minutes");
|
AppendMenuW(pause, MF_STRING, 21, L"&10 minutes");
|
||||||
AppendMenuW(pause, MF_STRING, 22, L"1 hour");
|
AppendMenuW(pause, MF_STRING, 22, L"1 &hour");
|
||||||
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
AppendMenuW(pause, MF_STRING, 23, L"&Until restart");
|
||||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"&Pause clipboard history");
|
||||||
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
AppendMenuW(menu, MF_STRING, 15, L"&Open data folder");
|
||||||
AppendMenuW(menu, MF_STRING, 18, L"View log…");
|
AppendMenuW(menu, MF_STRING, 18, L"View &log…");
|
||||||
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
||||||
updateRunning ? L"Update (checking / preparing…)" :
|
updateRunning ? L"&Update (checking / preparing…)" :
|
||||||
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
|
availableUpdate.empty() ? L"&Update" : L"&Update (new version available)");
|
||||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||||
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
AppendMenuW(menu, MF_STRING, 16, L"&About KSwitcher");
|
||||||
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
AppendMenuW(menu, MF_STRING, 17, L"E&xit");
|
||||||
POINT pt;
|
POINT pt;
|
||||||
GetCursorPos(&pt);
|
GetCursorPos(&pt);
|
||||||
pasteTarget = GetForegroundWindow();
|
pasteTarget = GetForegroundWindow();
|
||||||
@@ -1476,13 +1772,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->captureHistory();
|
app->captureHistory();
|
||||||
return 0;
|
return 0;
|
||||||
case AutoCorrectMessage:
|
case AutoCorrectMessage:
|
||||||
if (app->installingUpdate) return 0;
|
if (app->autoPending)
|
||||||
app->pendingAutoVersion = (uint64_t)wp;
|
{
|
||||||
SetTimer(hwnd, 3, 250, nullptr);
|
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||||
|
app->transform(AutoCorrect);
|
||||||
|
else
|
||||||
|
app->replayTyping();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case SnippetMessage:
|
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);
|
app->transform(0, (int)wp);
|
||||||
|
else
|
||||||
|
app->replayTyping();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case HistoryMessage:
|
case HistoryMessage:
|
||||||
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
||||||
@@ -1504,7 +1809,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COPYDATA:
|
case WM_COPYDATA:
|
||||||
{
|
{
|
||||||
auto *data = (COPYDATASTRUCT *)lp;
|
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;
|
if (app->installingUpdate) return FALSE;
|
||||||
return configurationRequest(*app, (HWND)wp, *data);
|
return configurationRequest(*app, (HWND)wp, *data);
|
||||||
@@ -1518,6 +1823,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
|
if (wp == 6)
|
||||||
|
{
|
||||||
|
app->pollSync();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (wp == 5)
|
if (wp == 5)
|
||||||
{
|
{
|
||||||
// After the initial 5-second check, repeat while the resident stays open.
|
// After the initial 5-second check, repeat while the resident stays open.
|
||||||
@@ -1534,13 +1844,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->dispatch(20);
|
app->dispatch(20);
|
||||||
return 0;
|
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)
|
if (wp == 2)
|
||||||
{
|
{
|
||||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||||
@@ -1627,6 +1930,7 @@ int App::execute()
|
|||||||
scheduleHistoryOcr(settings);
|
scheduleHistoryOcr(settings);
|
||||||
addTray();
|
addTray();
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
|
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
if (!registrationWarning.empty())
|
if (!registrationWarning.empty())
|
||||||
@@ -1651,6 +1955,9 @@ int App::execute()
|
|||||||
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
|
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
|
||||||
continue;
|
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))
|
if (settingsWindow && IsDialogMessageW(settingsWindow, &msg))
|
||||||
continue;
|
continue;
|
||||||
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include "update.h"
|
#include "update.h"
|
||||||
|
#include "sync.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -38,6 +39,7 @@ class App
|
|||||||
Settings settings;
|
Settings settings;
|
||||||
std::unique_ptr<History> history;
|
std::unique_ptr<History> history;
|
||||||
AutoCorrector autoCorrector;
|
AutoCorrector autoCorrector;
|
||||||
|
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||||
std::atomic<bool> busy{false}, suspended{false};
|
std::atomic<bool> busy{false}, suspended{false};
|
||||||
std::atomic<DWORD> ownSequence{0};
|
std::atomic<DWORD> ownSequence{0};
|
||||||
std::atomic<uint64_t> inputVersion{0};
|
std::atomic<uint64_t> inputVersion{0};
|
||||||
@@ -70,12 +72,16 @@ class App
|
|||||||
void applySettings(const Settings &);
|
void applySettings(const Settings &);
|
||||||
void reloadLayouts();
|
void reloadLayouts();
|
||||||
void clearHistory();
|
void clearHistory();
|
||||||
|
void clearLog(bool previous);
|
||||||
void setHotkeyCapture(HWND);
|
void setHotkeyCapture(HWND);
|
||||||
bool isHotkey(DWORD, UINT);
|
bool isHotkey(DWORD, UINT);
|
||||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||||
void resetInput();
|
void resetInput();
|
||||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
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;
|
static App *current;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -94,12 +100,21 @@ class App
|
|||||||
Settings historyOcrSettings;
|
Settings historyOcrSettings;
|
||||||
void scheduleHistoryOcr(const Settings &);
|
void scheduleHistoryOcr(const Settings &);
|
||||||
std::thread updateWorker;
|
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{};
|
bool updateRunning{}, installingUpdate{};
|
||||||
std::wstring availableUpdate, notifiedUpdate;
|
std::wstring availableUpdate, notifiedUpdate;
|
||||||
bool (*updateNotifier)(const std::wstring &){};
|
bool (*updateNotifier)(const std::wstring &){};
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
uint64_t pendingAutoVersion{};
|
|
||||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||||
ULONGLONG previousAttemptAt{};
|
ULONGLONG previousAttemptAt{};
|
||||||
HWND previousAttemptWindow{};
|
HWND previousAttemptWindow{};
|
||||||
@@ -107,8 +122,11 @@ class App
|
|||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
HWND typingTarget{};
|
HWND typingTarget{};
|
||||||
|
bool autoPending{};
|
||||||
|
int snippetPending = -1;
|
||||||
|
bool snippetTyping{};
|
||||||
std::vector<INPUT> deferredTyping;
|
std::vector<INPUT> deferredTyping;
|
||||||
bool beginTypingDeferral(HWND, uint64_t);
|
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||||
void replayTyping();
|
void replayTyping();
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||||
@@ -116,6 +134,7 @@ class App
|
|||||||
};
|
};
|
||||||
void showHistory(App &, bool visible = true);
|
void showHistory(App &, bool visible = true);
|
||||||
void launchScreenshot(App &);
|
void launchScreenshot(App &);
|
||||||
|
void launchScreenshotEditor(App &, const Clip &);
|
||||||
void refreshHistory(App &);
|
void refreshHistory(App &);
|
||||||
void showSettings(App &, bool visible = true);
|
void showSettings(App &, bool visible = true);
|
||||||
void showLogs(App &);
|
void showLogs(App &);
|
||||||
|
|||||||
@@ -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());
|
suffix.insert(suffix.begin(), word.back());
|
||||||
word.pop_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 {};
|
return {};
|
||||||
for (auto &never : settings.autoNever)
|
for (auto &never : settings.autoNever)
|
||||||
if (lower(never) == lower(word))
|
if (lower(never) == lower(word))
|
||||||
@@ -160,6 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||||
if (!dictionaries.contains(sourceLanguage))
|
if (!dictionaries.contains(sourceLanguage))
|
||||||
return {};
|
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 personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
{
|
{
|
||||||
auto found = settings.autoWords.find(language);
|
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))
|
if (d.rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
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)
|
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||||
{
|
{
|
||||||
if (i == source || !layouts[i].convertible ||
|
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);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (++matches > 1)
|
if (matchedText && *matchedText != converted)
|
||||||
return {};
|
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};
|
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||||
}
|
}
|
||||||
|
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||||||
return candidate;
|
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,
|
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||||||
const Layout &physical, const Settings &settings)
|
const Layout &physical, const Settings &settings)
|
||||||
|
|||||||
+26
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (wp == 21) showSnippets(*app);
|
if (wp == 21) showSnippets(*app);
|
||||||
|
else if (wp == 22) showLogs(*app);
|
||||||
else showSettings(*app);
|
else showSettings(*app);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
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->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||||
|
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||||
PostQuitMessage(0);
|
PostQuitMessage(0);
|
||||||
return 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)
|
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
||||||
{
|
{
|
||||||
current = this;
|
current = this;
|
||||||
|
data = dataDirectory();
|
||||||
layouts = readLayouts();
|
layouts = readLayouts();
|
||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||||
|
if (logWindow) DestroyWindow(logWindow);
|
||||||
if (window) DestroyWindow(window);
|
if (window) DestroyWindow(window);
|
||||||
current = nullptr;
|
current = nullptr;
|
||||||
}
|
}
|
||||||
@@ -107,6 +111,13 @@ void App::reloadLayouts()
|
|||||||
settings = decodeSettings(bytes, layouts);
|
settings = decodeSettings(bytes, layouts);
|
||||||
}
|
}
|
||||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
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)
|
void App::setHotkeyCapture(HWND field)
|
||||||
{
|
{
|
||||||
hotkeyCapture = 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);
|
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");
|
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
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.");
|
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||||
AllowSetForegroundWindow(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;
|
return 0;
|
||||||
}
|
}
|
||||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
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);
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
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;
|
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))
|
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||||
{
|
{
|
||||||
HWND popup = GetLastActivePopup(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 (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
||||||
}
|
}
|
||||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||||
|
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -193,8 +212,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
{
|
{
|
||||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||||
|
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||||
LocalFree(args);
|
LocalFree(args);
|
||||||
result = ks::runConfigurator(instance, snippets);
|
result = ks::runConfigurator(instance, snippets, logs);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
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;
|
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
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";
|
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
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)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
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)
|
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:
|
case ConfigSave:
|
||||||
{
|
{
|
||||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
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:
|
case ConfigLogs:
|
||||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||||
break;
|
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;
|
default: return FALSE;
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
|
|||||||
+4
-2
@@ -5,9 +5,11 @@ namespace ks
|
|||||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
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;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
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 &);
|
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);
|
return best < 0 ? text : convert(text, layouts[best], to);
|
||||||
}
|
}
|
||||||
|
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||||
|
const std::vector<int> &cycle, int hint)
|
||||||
|
{
|
||||||
|
if (cycle.empty())
|
||||||
|
return -1;
|
||||||
|
int source = detect(text, layouts, hint);
|
||||||
|
if (source < 0)
|
||||||
|
source = hint;
|
||||||
|
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||||
|
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||||
|
// Compare every candidate with the original input, never an intermediate conversion.
|
||||||
|
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||||
|
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||||
|
{
|
||||||
|
int target = cycle[(start + offset) % cycle.size()];
|
||||||
|
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return cycle[start];
|
||||||
|
}
|
||||||
std::wstring cycleCase(const std::wstring &text)
|
std::wstring cycleCase(const std::wstring &text)
|
||||||
{
|
{
|
||||||
if (text == upper(text))
|
if (text == upper(text))
|
||||||
@@ -261,7 +281,17 @@ Template parseTemplate(const std::wstring &text)
|
|||||||
size_t cursor = std::wstring::npos;
|
size_t cursor = std::wstring::npos;
|
||||||
for (size_t i = 0; i < text.size();)
|
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}";
|
t.text += L"{cursor}";
|
||||||
i += 10;
|
i += 10;
|
||||||
@@ -449,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
|||||||
keys.pop_back();
|
keys.pop_back();
|
||||||
at = now;
|
at = now;
|
||||||
}
|
}
|
||||||
|
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||||
|
{
|
||||||
|
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||||
|
return -1;
|
||||||
|
Run tail = *this;
|
||||||
|
tail.keys = keys.substr(keys.size() - text.size());
|
||||||
|
return tail.match(text, layouts, physical);
|
||||||
|
}
|
||||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||||
{
|
{
|
||||||
if (text.size() != keys.size() || overflow)
|
if (text.size() != keys.size() || overflow)
|
||||||
|
|||||||
+10
@@ -24,16 +24,22 @@ std::wstring upper(std::wstring text);
|
|||||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||||
|
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||||
|
const std::vector<int> &cycle, int hint);
|
||||||
std::wstring cycleCase(const std::wstring &);
|
std::wstring cycleCase(const std::wstring &);
|
||||||
std::wstring transliterate(const std::wstring &);
|
std::wstring transliterate(const std::wstring &);
|
||||||
struct Snippet
|
struct Snippet
|
||||||
{
|
{
|
||||||
std::wstring trigger, text;
|
std::wstring trigger, text;
|
||||||
|
std::wstring id;
|
||||||
};
|
};
|
||||||
|
std::wstring snippetId(const Snippet &);
|
||||||
|
std::wstring newSnippetId();
|
||||||
struct Template
|
struct Template
|
||||||
{
|
{
|
||||||
std::wstring text, suffix;
|
std::wstring text, suffix;
|
||||||
int cursors = 0;
|
int cursors = 0;
|
||||||
|
int enters = 0;
|
||||||
};
|
};
|
||||||
Template parseTemplate(const std::wstring &);
|
Template parseTemplate(const std::wstring &);
|
||||||
struct Hotkey
|
struct Hotkey
|
||||||
@@ -48,6 +54,8 @@ struct Settings
|
|||||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||||
bool elevatedAutostart = false;
|
bool elevatedAutostart = false;
|
||||||
bool autoCorrect = false;
|
bool autoCorrect = false;
|
||||||
|
bool autoReplace = false, autoTypos = false;
|
||||||
|
std::map<std::wstring, std::wstring> autoReplacements;
|
||||||
bool writeLog = true;
|
bool writeLog = true;
|
||||||
bool screenshotAutoOpenEditor = false;
|
bool screenshotAutoOpenEditor = false;
|
||||||
bool useTesseract = false;
|
bool useTesseract = false;
|
||||||
@@ -70,6 +78,7 @@ struct Settings
|
|||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
|
std::vector<std::wstring> snippetTombstones;
|
||||||
};
|
};
|
||||||
// Consume repeats and key-up for intercepted chords, even if settings change.
|
// Consume repeats and key-up for intercepted chords, even if settings change.
|
||||||
struct HotkeyHook
|
struct HotkeyHook
|
||||||
@@ -91,5 +100,6 @@ struct Run
|
|||||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||||
void backspace(HWND, ULONGLONG);
|
void backspace(HWND, ULONGLONG);
|
||||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||||
|
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||||
};
|
};
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -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()));
|
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
||||||
return pixels;
|
return pixels;
|
||||||
}
|
}
|
||||||
|
Bytes encodePng(const Pixels &p)
|
||||||
|
{
|
||||||
|
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
|
||||||
|
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
|
||||||
|
throw std::runtime_error("Invalid PNG image dimensions");
|
||||||
|
Com<IWICImagingFactory> factory;
|
||||||
|
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||||
|
IID_PPV_ARGS(factory.out())));
|
||||||
|
Com<IStream> stream;
|
||||||
|
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
|
||||||
|
Com<IWICBitmapEncoder> encoder;
|
||||||
|
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
|
||||||
|
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
|
||||||
|
Com<IWICBitmapFrameEncode> frame;
|
||||||
|
check(encoder->CreateNewFrame(frame.out(), nullptr));
|
||||||
|
check(frame->Initialize(nullptr));
|
||||||
|
check(frame->SetSize(p.width, p.height));
|
||||||
|
auto format = GUID_WICPixelFormat32bppBGRA;
|
||||||
|
check(frame->SetPixelFormat(&format));
|
||||||
|
if (format != GUID_WICPixelFormat32bppBGRA)
|
||||||
|
throw std::runtime_error("Unsupported PNG pixel format");
|
||||||
|
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
|
||||||
|
const_cast<BYTE *>(p.bgra.data())));
|
||||||
|
check(frame->Commit());
|
||||||
|
check(encoder->Commit());
|
||||||
|
STATSTG stat{};
|
||||||
|
check(stream->Stat(&stat, STATFLAG_NONAME));
|
||||||
|
if (stat.cbSize.QuadPart > UINT_MAX)
|
||||||
|
throw std::runtime_error("PNG image is too large");
|
||||||
|
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
|
||||||
|
Bytes bytes((size_t)stat.cbSize.QuadPart);
|
||||||
|
ULONG read{};
|
||||||
|
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
|
||||||
|
if (read != bytes.size())
|
||||||
|
throw std::runtime_error("Cannot read encoded PNG image");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
Format imageDib(const Pixels &p)
|
Format imageDib(const Pixels &p)
|
||||||
{
|
{
|
||||||
BITMAPINFOHEADER h{};
|
BITMAPINFOHEADER h{};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct Pixels
|
|||||||
};
|
};
|
||||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||||
Format imageDib(const Pixels &);
|
Format imageDib(const Pixels &);
|
||||||
|
Bytes encodePng(const Pixels &);
|
||||||
std::wstring recognizeText(const Clip &);
|
std::wstring recognizeText(const Clip &);
|
||||||
std::wstring recognizeText(const Clip &, const Settings &);
|
std::wstring recognizeText(const Clip &, const Settings &);
|
||||||
std::filesystem::path tesseractExecutable(const Settings &);
|
std::filesystem::path tesseractExecutable(const Settings &);
|
||||||
|
|||||||
@@ -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 "platform.h"
|
||||||
|
#include "../vendor/json.hpp"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
#include <imm.h>
|
#include <imm.h>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -394,22 +396,27 @@ bool chord(WORD key, UINT mods)
|
|||||||
bool needsSelectionDelay(HWND target)
|
bool needsSelectionDelay(HWND target)
|
||||||
{
|
{
|
||||||
const auto name = lower(processName(target));
|
const auto name = lower(processName(target));
|
||||||
return name == L"ms-teams" || name == L"msedgewebview2";
|
return name == L"ms-teams" || name == L"msedgewebview2" ||
|
||||||
|
name == L"chrome" || name == L"msedge";
|
||||||
}
|
}
|
||||||
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||||
{
|
{
|
||||||
if (count > 100000)
|
if (count > 100000)
|
||||||
return false;
|
return false;
|
||||||
// Teams' WebView editor applies selection changes asynchronously. A burst of
|
// Chromium and Teams' WebView editors apply selection changes asynchronously. A burst of
|
||||||
// Shift+Left events can all use the same caret position and select only one
|
// Shift+Left events can all use the same caret position and select only one
|
||||||
// character. Give each change time to settle before sending the next one.
|
// character. Give each change time to settle before sending the next one.
|
||||||
const auto target = GetForegroundWindow();
|
const auto target = GetForegroundWindow();
|
||||||
|
diagnosticEvent(L"SelectPrevious requested=" + std::to_wstring(count) +
|
||||||
|
L" mode=" + (needsSelectionDelay(target) ? std::wstring(L"paced-25ms") : std::wstring(L"batch")));
|
||||||
if (needsSelectionDelay(target))
|
if (needsSelectionDelay(target))
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < count; ++i)
|
for (size_t i = 0; i < count; ++i)
|
||||||
{
|
{
|
||||||
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
||||||
return false;
|
return false;
|
||||||
|
if (i < 32 || i + 1 == count)
|
||||||
|
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
return valid() && GetForegroundWindow() == target;
|
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_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
|
||||||
}
|
}
|
||||||
add(VK_SHIFT, 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 false;
|
||||||
}
|
}
|
||||||
return valid();
|
return valid();
|
||||||
@@ -571,7 +581,7 @@ DiagnosticBuffer diagnosticBuffer;
|
|||||||
}
|
}
|
||||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
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.pop_front();
|
||||||
entries.emplace_back(now, message);
|
entries.emplace_back(now, message);
|
||||||
if (!problem)
|
if (!problem)
|
||||||
@@ -624,6 +634,22 @@ void diagnosticEvent(const std::wstring &message, bool problem)
|
|||||||
if (!incident.empty())
|
if (!incident.empty())
|
||||||
log(incident);
|
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)
|
void log(const std::wstring &message)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(logGate);
|
std::lock_guard lock(logGate);
|
||||||
@@ -645,8 +671,9 @@ void log(const std::wstring &message)
|
|||||||
std::ofstream f(path, std::ios::app);
|
std::ofstream f(path, std::ios::app);
|
||||||
SYSTEMTIME t;
|
SYSTEMTIME t;
|
||||||
GetLocalTime(&t);
|
GetLocalTime(&t);
|
||||||
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
|
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
|
||||||
<< t.wSecond << ' ' << utf8(message) << '\n';
|
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
|
||||||
|
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
inline std::wstring localBuildTitle(const std::wstring &title)
|
||||||
|
{
|
||||||
|
static const bool development=[] {
|
||||||
|
wchar_t path[32768]{};
|
||||||
|
if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false;
|
||||||
|
std::error_code error;
|
||||||
|
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
|
||||||
|
}();
|
||||||
|
return development?title+L" [LOCAL DEV]":title;
|
||||||
|
}
|
||||||
using Bytes = std::vector<unsigned char>;
|
using Bytes = std::vector<unsigned char>;
|
||||||
struct Format
|
struct Format
|
||||||
{
|
{
|
||||||
@@ -66,6 +76,7 @@ class DiagnosticBuffer
|
|||||||
};
|
};
|
||||||
void setLoggingEnabled(bool enabled);
|
void setLoggingEnabled(bool enabled);
|
||||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||||
|
void diagnosticOperation(const std::string &json);
|
||||||
std::wstring readDiagnosticLog(bool previous);
|
std::wstring readDiagnosticLog(bool previous);
|
||||||
void clearDiagnosticLog(bool previous);
|
void clearDiagnosticLog(bool previous);
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
+424
-36
@@ -1,4 +1,5 @@
|
|||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
|
#include "stroke_improvement.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -28,8 +29,8 @@ bool confirmScreenshotClose(HWND owner)
|
|||||||
dialog.hMainIcon=icon;
|
dialog.hMainIcon=icon;
|
||||||
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
||||||
dialog.pszWindowTitle=L"KSwitcher";
|
dialog.pszWindowTitle=L"KSwitcher";
|
||||||
dialog.pszMainInstruction=L"Close without copying?";
|
dialog.pszMainInstruction=L"Close without saving or copying?";
|
||||||
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
|
dialog.pszContent=L"Your latest screenshot changes have not been saved to a file or copied to the clipboard.";
|
||||||
dialog.nDefaultButton=IDNO;
|
dialog.nDefaultButton=IDNO;
|
||||||
dialog.lpCallbackData=(LONG_PTR)owner;
|
dialog.lpCallbackData=(LONG_PTR)owner;
|
||||||
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
||||||
@@ -53,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
|
|||||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||||
return SUCCEEDED(result) && button==IDYES;
|
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
|
struct Surface
|
||||||
{
|
{
|
||||||
HDC dc = CreateCompatibleDC(nullptr);
|
HDC dc = CreateCompatibleDC(nullptr);
|
||||||
@@ -123,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
|
|||||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||||
return true;
|
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)
|
HCURSOR makeToolCursor(int tool,int size)
|
||||||
{
|
{
|
||||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
// 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 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 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 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:
|
case 19:
|
||||||
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
|
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;
|
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 currentColor=RGB(225,42,50);
|
||||||
COLORREF customColors[16]{};
|
COLORREF customColors[16]{};
|
||||||
Mark stroke{};
|
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{};
|
POINT start{}, end{};
|
||||||
RECT selection{}, view{};
|
RECT selection{}, view{};
|
||||||
|
RECT canvas{};
|
||||||
|
double zoom=1,panX=0,panY=0;
|
||||||
|
bool panning=false;
|
||||||
|
POINT panOrigin{};
|
||||||
|
int zoomWheelDelta=0;
|
||||||
std::vector<Mark> marks;
|
std::vector<Mark> marks;
|
||||||
ScreenshotHistory history;
|
ScreenshotHistory history;
|
||||||
HWND editor{}, inlineText{};
|
HWND editor{}, inlineText{};
|
||||||
@@ -203,6 +217,7 @@ struct Capture
|
|||||||
int hoveredTool=0;
|
int hoveredTool=0;
|
||||||
MarkPart hoveredPart=MarkPart::None;
|
MarkPart hoveredPart=MarkPart::None;
|
||||||
std::wstring statusText;
|
std::wstring statusText;
|
||||||
|
std::filesystem::path savedPath;
|
||||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
||||||
~Capture() {
|
~Capture() {
|
||||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||||
@@ -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==30) text=L"Line style: solid, dotted, dashed or dash-dot";
|
||||||
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
|
if(c.hoveredTool==31) text=L"Line ends: start, end, both or no arrowheads";
|
||||||
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
|
if(c.hoveredTool==35) text=L"Arrow caption: F2 / double-click to edit; drag to reposition";
|
||||||
|
if(c.hoveredTool==28) text=L"Save (Ctrl+S) | Save As... (Ctrl+Shift+S)";
|
||||||
}
|
}
|
||||||
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
|
else if(c.inlineText) text=L"Text: Enter = new line | Ctrl+Enter = apply | Esc = cancel";
|
||||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
else if(part==MarkPart::Rotation) text=L"Drag to rotate | 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::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::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(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) {
|
else if(c.selected>=0) {
|
||||||
const auto &m=c.marks[c.selected];
|
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.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.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.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 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";
|
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) {
|
if(text!=c.statusText) {
|
||||||
c.statusText=std::move(text);
|
c.statusText=std::move(text);
|
||||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
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 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));
|
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);
|
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)
|
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)
|
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)))) {
|
if(msg==WM_KEYDOWN && (wp==VK_ESCAPE || (wp==VK_RETURN && (GetKeyState(VK_CONTROL)&0x8000)))) {
|
||||||
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
|
PostMessageW(GetParent(w),FinishTextMessage,wp==VK_ESCAPE?2:1,(LPARAM)w); return 0;
|
||||||
}
|
}
|
||||||
@@ -361,9 +394,47 @@ void beginMarkText(HWND w,Capture &c,int index)
|
|||||||
beginInlineText(w,c,label,index); c.editingLabel=true;
|
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)
|
RECT markBounds(const Mark &m)
|
||||||
{
|
{
|
||||||
if(m.isText()) return screenshotTextBounds(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);
|
RECT r=region(m.from,m.to);
|
||||||
for(auto p:m.points) {
|
for(auto p:m.points) {
|
||||||
r.left=std::min(r.left,p.x); r.top=std::min(r.top,p.y);
|
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) {
|
if(m.shape!=ScreenshotShape::None) {
|
||||||
auto r=region(m.from,m.to);
|
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) {
|
if(m.freehand && m.points.size()>1) {
|
||||||
size_t index=(m.points.size()-1)/2;
|
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) {
|
if(m.shape!=ScreenshotShape::None) {
|
||||||
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
||||||
auto r=region(m.from,m.to);
|
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);
|
else Ellipse(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||||
SelectObject(dc,hollow);
|
SelectObject(dc,hollow);
|
||||||
SelectObject(dc,previousPen); DeleteObject(arrowPen);
|
SelectObject(dc,previousPen); DeleteObject(arrowPen);
|
||||||
@@ -594,7 +668,7 @@ void layout(HWND w, Capture &c)
|
|||||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||||
r.bottom-=c.statusHeight;
|
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));
|
static_assert(std::size(positions)==std::size(ToolIds));
|
||||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
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),
|
MoveWindow(GetDlgItem(w,ToolIds[i]),MulDiv(positions[i],dpi,96),MulDiv(6,dpi,96),
|
||||||
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||||
}
|
}
|
||||||
|
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||||
|
MoveWindow(GetDlgItem(w,28),MulDiv(612,dpi,96),MulDiv(6,dpi,96),
|
||||||
|
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||||
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
||||||
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||||
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||||
@@ -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,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);
|
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);
|
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});
|
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);
|
int width = std::max(1,int(std::lround(c.image->width*ratio))), height = std::max(1,int(std::lround(c.image->height*ratio)));
|
||||||
c.view = {(r.right-width)/2, c.panelHeight+(r.bottom-c.panelHeight-height)/2,
|
double maxX=std::max(0.0,(width-double(c.canvas.right-c.canvas.left))/2);
|
||||||
(r.right+width)/2, c.panelHeight+(r.bottom-c.panelHeight+height)/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);
|
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 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)),
|
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)
|
if(c.selected>=0)
|
||||||
{
|
{
|
||||||
const auto &m=c.marks[c.selected];
|
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())) {
|
if(m.isText() || (m.isLine() && !m.label.empty())) {
|
||||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
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));
|
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.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
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)
|
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||||
return LoadCursorW(nullptr,IDC_CROSS);
|
return LoadCursorW(nullptr,IDC_CROSS);
|
||||||
@@ -679,6 +782,7 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
|||||||
LPCWSTR cursor=nullptr;
|
LPCWSTR cursor=nullptr;
|
||||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
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::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::Radius) cursor=IDC_SIZEWE;
|
||||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
||||||
const auto &mark=c.marks[hit.first];
|
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)
|
void captureCursor(HWND w, Capture &c)
|
||||||
{
|
{
|
||||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
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};
|
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};
|
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||||
SetCursor(actionCursor(c,hit,inside || c.dragging));
|
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});
|
handle({m.from.x+m.radius,m.from.y});
|
||||||
}
|
}
|
||||||
else {
|
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 [from,to]=thicknessSegment(m);
|
||||||
auto mid=point({(from.x+to.x)/2,(from.y+to.y)/2});
|
auto mid=point({(from.x+to.x)/2,(from.y+to.y)/2});
|
||||||
auto grip=thicknessHandle(c,m),end=point(grip);
|
auto grip=thicknessHandle(c,m),end=point(grip);
|
||||||
@@ -766,7 +881,8 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
RECT r=item.rcItem;
|
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)||
|
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==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;
|
bool disabled=item.itemState&ODS_DISABLED;
|
||||||
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
||||||
bool hover=PtInRect(&r,mouse) && !disabled;
|
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);
|
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
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;
|
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(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) {
|
if(item.CtlID==20) {
|
||||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
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);
|
publish(c.app.window, clip);
|
||||||
c.copied=true;
|
c.copied=true;
|
||||||
}
|
}
|
||||||
|
void saveToFile(HWND w, Capture &c, const std::filesystem::path &path)
|
||||||
|
{
|
||||||
|
finishInlineText(w,c,true);
|
||||||
|
auto pixels=decodeImage(screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks));
|
||||||
|
// GDI-rendered screenshot pixels are opaque; GDI does not maintain alpha.
|
||||||
|
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||||
|
auto bytes=encodePng(pixels);
|
||||||
|
GUID id{};
|
||||||
|
if(FAILED(CoCreateGuid(&id))) throw std::runtime_error("Cannot prepare screenshot output file");
|
||||||
|
wchar_t unique[40]{}; StringFromGUID2(id,unique,(int)std::size(unique));
|
||||||
|
auto temporary=path.parent_path()/(L".kswitcher-"+std::wstring(unique)+L".png");
|
||||||
|
try {
|
||||||
|
atomicWrite(temporary,bytes);
|
||||||
|
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH))
|
||||||
|
throw std::runtime_error("Cannot save screenshot to the selected file");
|
||||||
|
} catch(...) {
|
||||||
|
std::error_code ignored; std::filesystem::remove(temporary,ignored); throw;
|
||||||
|
}
|
||||||
|
c.savedPath=path;
|
||||||
|
c.copied=true; // Current edits have been exported, either to file or clipboard.
|
||||||
|
}
|
||||||
|
void save(HWND w, Capture &c, bool saveAs=false,
|
||||||
|
BOOL (WINAPI *chooseFile)(LPOPENFILENAMEW)=GetSaveFileNameW)
|
||||||
|
{
|
||||||
|
if(!saveAs && !c.savedPath.empty()) {
|
||||||
|
saveToFile(w,c,c.savedPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wchar_t filename[32768]{};
|
||||||
|
auto proposed=c.savedPath.wstring();
|
||||||
|
if(proposed.empty()) {
|
||||||
|
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||||
|
wchar_t name[100]{};
|
||||||
|
swprintf_s(name,L"Screenshot-%04u%02u%02u-%02u%02u%02u.png",
|
||||||
|
time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond);
|
||||||
|
proposed=name;
|
||||||
|
}
|
||||||
|
if(proposed.size()>=std::size(filename)) throw std::runtime_error("Screenshot file path is too long");
|
||||||
|
std::copy(proposed.begin(),proposed.end(),filename);
|
||||||
|
OPENFILENAMEW dialog{sizeof(dialog)};
|
||||||
|
dialog.hwndOwner=w;
|
||||||
|
dialog.lpstrTitle=L"Save screenshot";
|
||||||
|
dialog.lpstrFilter=L"PNG image (*.png)\0*.png\0\0";
|
||||||
|
dialog.nFilterIndex=1;
|
||||||
|
dialog.lpstrFile=filename;
|
||||||
|
dialog.nMaxFile=(DWORD)std::size(filename);
|
||||||
|
dialog.lpstrDefExt=L"png";
|
||||||
|
dialog.Flags=OFN_OVERWRITEPROMPT|OFN_PATHMUSTEXIST|OFN_NOCHANGEDIR|OFN_EXPLORER;
|
||||||
|
if(!chooseFile(&dialog)) {
|
||||||
|
if(CommDlgExtendedError()) throw std::runtime_error("Cannot open the screenshot save dialog");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(lower(std::filesystem::path(filename).extension().wstring())!=L".png")
|
||||||
|
throw std::runtime_error("Choose a filename ending in .png for the screenshot");
|
||||||
|
saveToFile(w,c,filename);
|
||||||
|
c.statusText=L"Saved: "+c.savedPath.wstring();
|
||||||
|
SendDlgItemMessageW(w,50,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||||
|
}
|
||||||
void edit(HWND w, Capture &c)
|
void edit(HWND w, Capture &c)
|
||||||
{
|
{
|
||||||
auto r = region(c.start,c.end);
|
auto r = region(c.start,c.end);
|
||||||
@@ -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_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
|
||||||
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
|
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
|
||||||
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
|
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
|
||||||
SetWindowTextW(w,title.c_str());
|
SetWindowTextW(w,localBuildTitle(title).c_str());
|
||||||
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
|
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
|
||||||
InitCommonControlsEx(&statusControls);
|
InitCommonControlsEx(&statusControls);
|
||||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
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)"};
|
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||||
static_assert(std::size(labels)==std::size(ToolIds));
|
static_assert(std::size(labels)==std::size(ToolIds));
|
||||||
|
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||||
|
0,0,0,0,w,(HMENU)28,c.app.instance,nullptr);
|
||||||
|
SetWindowSubclass(saveButton,toolHover,1,0);
|
||||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||||
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
|
CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,w,nullptr,c.app.instance,nullptr);
|
||||||
for (size_t i=0;i<std::size(ToolIds);++i)
|
for (size_t i=0;i<std::size(ToolIds);++i)
|
||||||
@@ -933,7 +1119,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case WM_DRAWITEM:
|
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;
|
break;
|
||||||
case WM_CTLCOLORSTATIC:
|
case WM_CTLCOLORSTATIC:
|
||||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
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(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||||
|
if (LOWORD(wp)==28 || LOWORD(wp)==29) {
|
||||||
|
save(w,*c,LOWORD(wp)==29); SetFocus(w); return 0;
|
||||||
|
}
|
||||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||||
{
|
{
|
||||||
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
||||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||||
c->syncHistory();
|
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->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->straight=LOWORD(wp)==22;
|
||||||
c->textTool=LOWORD(wp)==26;
|
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->selected=-1; c->selectedLabel=false;
|
||||||
c->highlight=LOWORD(wp)==19;
|
c->highlight=LOWORD(wp)==19;
|
||||||
c->freehand=LOWORD(wp)==21;
|
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,19),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),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;
|
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
case WM_KEYDOWN:
|
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 (wp==VK_ESCAPE) {
|
||||||
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (c->editing && !c->dragging)
|
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) {
|
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};
|
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];
|
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;
|
beginMarkText(w,*c,c->selected); return 0;
|
||||||
}
|
}
|
||||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
|
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='C') { copy(w,*c); return 0; }
|
||||||
|
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='S') {
|
||||||
|
save(w,*c,(GetKeyState(VK_SHIFT)&0x8000)!=0); SetFocus(w); return 0;
|
||||||
|
}
|
||||||
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
|
if ((GetKeyState(VK_CONTROL)&0x8000) && (wp=='Z' || wp=='Y'))
|
||||||
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
|
SendMessageW(w,WM_COMMAND,wp=='Y'||(GetKeyState(VK_SHIFT)&0x8000)?18:12,0);
|
||||||
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
|
if ((GetKeyState(VK_CONTROL)&0x8000) && wp=='D' && c->selected>=0)
|
||||||
@@ -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=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||||
if(wp=='O') SendMessageW(w,WM_COMMAND,24,0);
|
if(wp=='O') SendMessageW(w,WM_COMMAND,24,0);
|
||||||
if(wp=='E') SendMessageW(w,WM_COMMAND,25,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=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
||||||
if(wp=='H') SendMessageW(w,WM_COMMAND,19,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;
|
return 0;
|
||||||
|
case WM_KEYUP:
|
||||||
|
if(wp==VK_SHIFT) c->strokeConstrained=false;
|
||||||
|
return 0;
|
||||||
case WM_LBUTTONDBLCLK:
|
case WM_LBUTTONDBLCLK:
|
||||||
if(c->editing && !c->dragging) {
|
if(c->editing && !c->dragging) {
|
||||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
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));
|
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||||
if(index>=0 && (c->marks[index].isText() || c->marks[index].isLine())) {
|
if(index>=0 && (c->marks[index].isText() || c->marks[index].isLine())) {
|
||||||
c->selected=index; beginMarkText(w,*c,index);
|
c->selected=index; beginMarkText(w,*c,index);
|
||||||
@@ -1113,8 +1326,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
case WM_LBUTTONDOWN:
|
case WM_LBUTTONDOWN:
|
||||||
{
|
{
|
||||||
|
if(c->panning) return 0;
|
||||||
finishInlineText(w,*c,true);
|
finishInlineText(w,*c,true);
|
||||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
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;
|
auto hitPart=c->editing?markAt(*c,imagePoint(*c,p,false)).second:MarkPart::None;
|
||||||
bool thickness=hitPart==MarkPart::Thickness || hitPart==MarkPart::Rotation;
|
bool thickness=hitPart==MarkPart::Thickness || hitPart==MarkPart::Rotation;
|
||||||
if (c->editing && !PtInRect(&c->view,p) && !thickness)
|
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={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.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||||
c->stroke.points.push_back(p);
|
c->stroke.points.push_back(p);
|
||||||
|
c->strokeConstrained=false;
|
||||||
|
c->strokeUsedConstraint=(wp&MK_SHIFT)!=0;
|
||||||
}
|
}
|
||||||
c->dragging=true; SetCapture(w);
|
c->dragging=true; SetCapture(w);
|
||||||
}
|
}
|
||||||
@@ -1163,8 +1380,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
case WM_MOUSEWHEEL:
|
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);
|
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;
|
if(!PtInRect(&c->view,p)) return 0;
|
||||||
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
auto [index,part]=markAt(*c,imagePoint(*c,p));
|
||||||
if(index<0) { c->wheelDelta=0; c->wheelTarget=-1; return 0; }
|
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;
|
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_MOUSEMOVE:
|
||||||
case WM_LBUTTONUP:
|
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)
|
if (c->dragging)
|
||||||
{
|
{
|
||||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
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)
|
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);
|
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) {
|
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();
|
auto last=c->stroke.points.back();
|
||||||
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
||||||
c->stroke.to=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);
|
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||||
resolveScreenshotLinks(c->marks);
|
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;
|
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)
|
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().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
||||||
c->marks.back().shape=c->shape;
|
c->marks.back().shape=c->shape;
|
||||||
auto &mark=c->marks.back();
|
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,true,c->start,c->marks,6);
|
||||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||||
resolveScreenshotLinks(c->marks);
|
resolveScreenshotLinks(c->marks);
|
||||||
@@ -1252,6 +1575,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
captureCursor(w,*c);
|
captureCursor(w,*c);
|
||||||
return 0;
|
return 0;
|
||||||
case WM_CAPTURECHANGED:
|
case WM_CAPTURECHANGED:
|
||||||
|
c->panning=false;
|
||||||
if(c->dragging && c->part!=MarkPart::None)
|
if(c->dragging && c->part!=MarkPart::None)
|
||||||
{
|
{
|
||||||
c->marks=std::move(c->beforeDrag); 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)
|
if (c->editing)
|
||||||
{
|
{
|
||||||
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1311,7 +1635,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto previous=SelectObject(buffer,divider);
|
auto previous=SelectObject(buffer,divider);
|
||||||
MoveToEx(buffer,0,c->panelHeight-1,nullptr); LineTo(buffer,client.right,c->panelHeight-1);
|
MoveToEx(buffer,0,c->panelHeight-1,nullptr); LineTo(buffer,client.right,c->panelHeight-1);
|
||||||
int dpi=GetDpiForWindow(w);
|
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);
|
MoveToEx(buffer,MulDiv(x,dpi,96),MulDiv(13,dpi,96),nullptr);
|
||||||
LineTo(buffer,MulDiv(x,dpi,96),MulDiv(31,dpi,96));
|
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);
|
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);
|
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,
|
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);
|
rendered.dc,0,0,rendered.width,rendered.height,SRCCOPY);
|
||||||
drawHandles(buffer,*c);
|
drawHandles(buffer,*c);
|
||||||
|
RestoreDC(buffer,savedCanvas);
|
||||||
BitBlt(dc,0,0,width,height,buffer,0,0,SRCCOPY);
|
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);
|
auto r=plainTextBounds(m);
|
||||||
return rotateTextPoint(m,{(r.left+r.right)/2,r.top-LONG(std::lround(clearance))});
|
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 screenshotArrowLabel(const ScreenshotMark &m)
|
||||||
{
|
{
|
||||||
ScreenshotMark label{{0,0},{0,0},0};
|
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)
|
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);
|
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))};
|
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;
|
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||||
double along=std::clamp(link.u,0.0,1.0)*segments;
|
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;
|
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;
|
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)
|
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
||||||
{
|
{
|
||||||
link={m.id,0,0};
|
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);
|
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 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;
|
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
||||||
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
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);
|
auto anchor=objectAnchor(m,link);
|
||||||
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
||||||
}
|
}
|
||||||
|
drop=rotateShapePoint(m,drop,-1);
|
||||||
double best=1e100;
|
double best=1e100;
|
||||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||||
for(size_t i=0;i<segments;++i) {
|
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)
|
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()) {
|
if(m.isText()) {
|
||||||
auto r=plainTextBounds(m);
|
auto r=plainTextBounds(m);
|
||||||
auto local=rotateTextPoint(m,p,-1);
|
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)); };
|
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.points.empty())
|
||||||
{
|
{
|
||||||
if(m.freehand && distance(p,m.from)<=tolerance) return MarkPart::Start;
|
if(m.shape!=ScreenshotShape::Triangle) {
|
||||||
if(m.freehand && distance(p,m.to)<=tolerance) return MarkPart::End;
|
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;
|
double reach=tolerance+std::clamp(m.thickness,m.isHighlight()?4:1,m.isHighlight()?100:30)/2.0;
|
||||||
POINT from=m.points.front();
|
POINT from=m.points.front();
|
||||||
for(auto to:m.points) {
|
for(auto to:m.points) {
|
||||||
@@ -1640,7 +1982,32 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
{
|
{
|
||||||
auto m=original;
|
auto m=original;
|
||||||
if(position.x==origin.x && position.y==origin.y) return m;
|
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;
|
if(position.x==origin.x && position.y==origin.y) return m;
|
||||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||||
auto r=plainTextBounds(text);
|
auto r=plainTextBounds(text);
|
||||||
@@ -1694,13 +2061,34 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
}
|
}
|
||||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
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.fromNumber:m.toNumber)=0;
|
||||||
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
||||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
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.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);
|
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.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;
|
return m;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -15,7 +15,7 @@ void showScreenshot(ScreenshotHost &);
|
|||||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||||
enum class LineEnds { End, Start, Both, None };
|
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 ScreenshotOffset { double x, y; };
|
||||||
struct ScreenshotLink {
|
struct ScreenshotLink {
|
||||||
unsigned long long target{};
|
unsigned long long target{};
|
||||||
@@ -40,7 +40,7 @@ struct ScreenshotMark {
|
|||||||
unsigned long long id{};
|
unsigned long long id{};
|
||||||
ScreenshotLink fromLink, toLink;
|
ScreenshotLink fromLink, toLink;
|
||||||
bool isText() const { return !text.empty(); }
|
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; }
|
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||||
};
|
};
|
||||||
class ScreenshotHistory {
|
class ScreenshotHistory {
|
||||||
@@ -55,7 +55,7 @@ class ScreenshotHistory {
|
|||||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||||
std::vector<ScreenshotMark> &, double tolerance);
|
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);
|
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||||
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
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);
|
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||||
RECT screenshotTextBounds(const ScreenshotMark &);
|
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||||
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||||
|
POINT screenshotShapeRotationHandle(const ScreenshotMark &, double clearance=32);
|
||||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||||
// Render at source resolution; never reads or writes the system clipboard.
|
// Render at source resolution; never reads or writes the system clipboard.
|
||||||
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
void launchScreenshot(App &app)
|
static void launch(App &app, const Clip *clip)
|
||||||
{
|
{
|
||||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
auto command=L"\""+path.wstring()+L"\" ";
|
||||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip);
|
||||||
|
else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||||
if(app.settings.writeLog) command+=L" --write-log";
|
if(app.settings.writeLog) command+=L" --write-log";
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
|
|||||||
CloseHandle(process.hThread);
|
CloseHandle(process.hThread);
|
||||||
CloseHandle(process.hProcess);
|
CloseHandle(process.hProcess);
|
||||||
}
|
}
|
||||||
|
void launchScreenshot(App &app) { launch(app,nullptr); }
|
||||||
|
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &clip)
|
||||||
|
{
|
||||||
|
// Normalize PNG/DIBV5 and other supported clipboard images at full resolution.
|
||||||
|
auto pixels=decodeImage(clip);
|
||||||
|
if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER))
|
||||||
|
throw std::runtime_error("Image is too large for the screenshot editor");
|
||||||
|
auto dib=imageDib(pixels);
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||||
|
std::wstring id(value+1,36);
|
||||||
|
atomicWrite(directory/(id+L".capture"),protect(dib.data));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||||
{
|
{
|
||||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
|||||||
DWORD startScreenshotActivationListener();
|
DWORD startScreenshotActivationListener();
|
||||||
bool validScreenshotToken(const std::wstring &);
|
bool validScreenshotToken(const std::wstring &);
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-2
@@ -8,6 +8,22 @@
|
|||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
using Json = nlohmann::json;
|
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
|
namespace
|
||||||
{
|
{
|
||||||
Bytes jsonBytes(const Json &j)
|
Bytes jsonBytes(const Json &j)
|
||||||
@@ -92,6 +108,19 @@ void validateSettings(const Settings &s)
|
|||||||
throw std::runtime_error("Unsupported auto-correction language");
|
throw std::runtime_error("Unsupported auto-correction language");
|
||||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||||
throw std::runtime_error("Too many auto-correction exceptions");
|
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)
|
for (const auto &[language, words] : s.autoWords)
|
||||||
{
|
{
|
||||||
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||||
@@ -107,8 +136,14 @@ void validateSettings(const Settings &s)
|
|||||||
s.maxImageMiB > 64)
|
s.maxImageMiB > 64)
|
||||||
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
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> 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)
|
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 ||
|
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
||||||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
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");
|
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.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||||
s.awake = j.value("preventScreenLock", false);
|
s.awake = j.value("preventScreenLock", false);
|
||||||
s.autoCorrect = j.value("autoCorrectLayout", 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.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
||||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
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"))
|
if (j.contains("snippets"))
|
||||||
for (auto &sn : j.at("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);
|
validateSettings(s);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -240,6 +290,11 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
for (auto &id : s.cycle)
|
for (auto &id : s.cycle)
|
||||||
j["layouts"].push_back(utf8(id));
|
j["layouts"].push_back(utf8(id));
|
||||||
j["autoCorrectLayout"] = s.autoCorrect;
|
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["autoCorrectMaxRank"] = s.autoMaxRank;
|
||||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
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["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
||||||
j["snippets"] = Json::array();
|
j["snippets"] = Json::array();
|
||||||
for (auto &sn : s.snippets)
|
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();
|
j["excludedApps"] = Json::array();
|
||||||
for (auto &name : s.excluded)
|
for (auto &name : s.excluded)
|
||||||
j["excludedApps"].push_back(utf8(name));
|
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 "../resources/resource.h"
|
||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "history_preview.h"
|
||||||
|
#include "ui_scroll.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
#include "ui_geometry.h"
|
#include "ui_geometry.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <commdlg.h>
|
#include <commdlg.h>
|
||||||
@@ -34,6 +37,8 @@ struct WindowLayout
|
|||||||
bool scrolling{}, arranging{}, history{};
|
bool scrolling{}, arranging{}, history{};
|
||||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
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)
|
WindowLayout *windowLayoutState(HWND w)
|
||||||
{
|
{
|
||||||
@@ -69,17 +74,52 @@ void arrangeWindow(HWND w)
|
|||||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||||
}
|
}
|
||||||
RECT client{}; GetClientRect(w, &client);
|
RECT client{}; GetClientRect(w, &client);
|
||||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
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; };
|
struct Move { HWND child; RECT rect; };
|
||||||
std::vector<Move> moves;
|
std::vector<Move> moves;
|
||||||
for (const auto &[child, r] : layout->controls)
|
for (const auto &[child, r] : layout->controls)
|
||||||
{
|
{
|
||||||
int id = GetDlgCtrlID(child);
|
int id = GetDlgCtrlID(child);
|
||||||
RECT next{scale(w, r.left) - layout->x,
|
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.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);
|
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);
|
RECT current{}; GetWindowRect(child, ¤t);
|
||||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
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,
|
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||||
GetModuleHandleW(nullptr), nullptr);
|
GetModuleHandleW(nullptr), nullptr);
|
||||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||||
|
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
|
||||||
|
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
|
||||||
|
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
|
||||||
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
||||||
{
|
{
|
||||||
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
||||||
@@ -279,7 +322,7 @@ std::wstring value(HWND w, int id)
|
|||||||
text.resize(n);
|
text.resize(n);
|
||||||
return text;
|
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());
|
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);
|
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
||||||
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
||||||
SetWindowSubclass(w, layoutProc, 2, 0);
|
SetWindowSubclass(w, layoutProc, 2, 0);
|
||||||
|
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
|
||||||
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
||||||
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
||||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||||
arrangeWindow(w);
|
arrangeWindow(w);
|
||||||
SetWindowTextW(w, title);
|
SetWindowTextW(w, localBuildTitle(title).c_str());
|
||||||
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
||||||
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
|
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
|
||||||
@@ -440,7 +484,17 @@ struct HistoryView
|
|||||||
std::map<std::wstring, std::string> richTexts;
|
std::map<std::wstring, std::string> richTexts;
|
||||||
HWND rich{};
|
HWND rich{};
|
||||||
std::set<std::wstring> pending;
|
std::set<std::wstring> pending;
|
||||||
|
bool scrolling{};
|
||||||
};
|
};
|
||||||
|
bool selectedImage(HWND w, HistoryView *v)
|
||||||
|
{
|
||||||
|
int i=(int)SendDlgItemMessageW(w,102,LB_GETCURSEL,0,0);
|
||||||
|
return i>=0 && i<(int)v->items.size() && v->items[i].image;
|
||||||
|
}
|
||||||
|
void updateHistoryActions(HWND w, HistoryView *v)
|
||||||
|
{
|
||||||
|
SetDlgItemTextW(w,114,selectedImage(w,v)?L"&Edit image":L"&Open item");
|
||||||
|
}
|
||||||
std::wstring selected(HWND w, HistoryView *v)
|
std::wstring selected(HWND w, HistoryView *v)
|
||||||
{
|
{
|
||||||
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
|
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
|
||||||
@@ -469,6 +523,7 @@ void search(HWND w, HistoryView *v)
|
|||||||
}
|
}
|
||||||
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
||||||
{
|
{
|
||||||
|
if(v->scrolling) return;
|
||||||
const auto id = entry.id;
|
const auto id = entry.id;
|
||||||
const bool image = entry.image;
|
const bool image = entry.image;
|
||||||
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
|
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
|
||||||
@@ -652,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
switch (msg)
|
switch (msg)
|
||||||
{
|
{
|
||||||
|
case ScrollActivity:
|
||||||
|
if((HWND)wp==GetDlgItem(w,102)) {
|
||||||
|
v->scrolling=lp!=0;
|
||||||
|
if(!v->scrolling) InvalidateRect((HWND)wp,nullptr,FALSE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
@@ -664,10 +725,18 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
|
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
{
|
{
|
||||||
|
updateHistoryActions(w,v);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||||
id = 110;
|
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);
|
auto entry = selected(w, v);
|
||||||
if (id == 110 || id == 111)
|
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)
|
if (result->generation != v->generation)
|
||||||
return 0;
|
return 0;
|
||||||
auto old = selected(w, v);
|
auto old = selected(w, v);
|
||||||
|
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||||
|
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
||||||
v->items = std::move(result->items);
|
v->items = std::move(result->items);
|
||||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
||||||
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
||||||
int select = 0;
|
int select = 0;
|
||||||
|
int restoreTop=0;
|
||||||
for (size_t i = 0; i < v->items.size(); ++i)
|
for (size_t i = 0; i < v->items.size(); ++i)
|
||||||
{
|
{
|
||||||
auto &e = v->items[i];
|
auto &e = v->items[i];
|
||||||
@@ -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());
|
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
|
||||||
if (e.id == old)
|
if (e.id == old)
|
||||||
select = (int)i;
|
select = (int)i;
|
||||||
|
if(e.id==topId) restoreTop=(int)i;
|
||||||
}
|
}
|
||||||
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
|
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
|
||||||
|
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
|
||||||
|
updateHistoryActions(w,v);
|
||||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
|
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
|
||||||
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
|
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -732,16 +807,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (result->generation != v->generation || !IsWindowVisible(w))
|
if (result->generation != v->generation || !IsWindowVisible(w))
|
||||||
return 0;
|
return 0;
|
||||||
v->pending.erase(result->id);
|
v->pending.erase(result->id);
|
||||||
// At most ~3.3 MiB, irrespective of history length or source image size.
|
auto list=GetDlgItem(w,102);
|
||||||
if (v->thumbnails.size() >= 32)
|
RECT client{}; GetClientRect(list,&client);
|
||||||
v->thumbnails.erase(v->thumbnails.begin());
|
std::set<std::wstring> visible;
|
||||||
if (v->richTexts.size() >= 32)
|
RECT changed{}; bool repaint=false;
|
||||||
v->richTexts.erase(v->richTexts.begin());
|
int top=(int)SendMessageW(list,LB_GETTOPINDEX,0,0);
|
||||||
|
for(int i=std::max(0,top);i<(int)v->items.size();++i) {
|
||||||
|
RECT row{}, intersection{};
|
||||||
|
if(SendMessageW(list,LB_GETITEMRECT,i,(LPARAM)&row)==LB_ERR || row.top>=client.bottom) break;
|
||||||
|
if(!IntersectRect(&intersection,&row,&client)) continue;
|
||||||
|
visible.insert(v->items[i].id);
|
||||||
|
if(v->items[i].id==result->id) { changed=intersection; repaint=true; }
|
||||||
|
}
|
||||||
if (!result->rtf.empty())
|
if (!result->rtf.empty())
|
||||||
v->richTexts[result->id] = std::move(result->rtf);
|
v->richTexts[result->id] = std::move(result->rtf);
|
||||||
else
|
else
|
||||||
v->thumbnails[result->id] = std::move(result->pixels);
|
v->thumbnails[result->id] = std::move(result->pixels);
|
||||||
InvalidateRect(GetDlgItem(w, 102), nullptr, FALSE);
|
trimHistoryPreviews(v->thumbnails,visible);
|
||||||
|
trimHistoryPreviews(v->richTexts,visible);
|
||||||
|
if(repaint) InvalidateRect(list,&changed,FALSE);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_MEASUREITEM:
|
case WM_MEASUREITEM:
|
||||||
@@ -760,15 +844,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case WM_CONTEXTMENU:
|
case WM_CONTEXTMENU:
|
||||||
{
|
{
|
||||||
|
auto list=GetDlgItem(w,102);
|
||||||
|
if((HWND)wp!=list) break;
|
||||||
|
POINT p{(short)LOWORD(lp),(short)HIWORD(lp)};
|
||||||
|
if(lp!=-1) {
|
||||||
|
POINT hit=p; ScreenToClient(list,&hit);
|
||||||
|
auto item=SendMessageW(list,LB_ITEMFROMPOINT,0,MAKELPARAM(hit.x,hit.y));
|
||||||
|
if(HIWORD(item)) return 0;
|
||||||
|
SendMessageW(list,LB_SETCURSEL,LOWORD(item),0);
|
||||||
|
updateHistoryActions(w,v);
|
||||||
|
} else {
|
||||||
|
RECT row{};
|
||||||
|
auto index=SendMessageW(list,LB_GETCURSEL,0,0);
|
||||||
|
if(index==LB_ERR) return 0;
|
||||||
|
SendMessageW(list,LB_GETITEMRECT,index,(LPARAM)&row);
|
||||||
|
p={row.left,row.top}; ClientToScreen(list,&p);
|
||||||
|
}
|
||||||
HMENU menu = CreatePopupMenu();
|
HMENU menu = CreatePopupMenu();
|
||||||
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
AppendMenuW(menu, MF_STRING, 110, L"&Paste");
|
||||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
AppendMenuW(menu, MF_STRING, 111, L"Paste plain &text");
|
||||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
AppendMenuW(menu, MF_STRING, 112, L"P&in / unpin");
|
||||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
AppendMenuW(menu, MF_STRING, 113, L"&Delete");
|
||||||
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
|
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"&Edit image in screenshot editor":L"&Open full item");
|
||||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
|
AppendMenuW(menu, MF_STRING, 115, L"&Copy text from image (OCR)");
|
||||||
POINT p;
|
|
||||||
GetCursorPos(&p);
|
|
||||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
||||||
DestroyMenu(menu);
|
DestroyMenu(menu);
|
||||||
if (cmd)
|
if (cmd)
|
||||||
@@ -1021,6 +1119,25 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||||
draft.autoPrefixChars = value(w, 408);
|
draft.autoPrefixChars = value(w, 408);
|
||||||
draft.autoSuffixChars = value(w, 409);
|
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)
|
if (draft.autoLanguages.size() < 2)
|
||||||
throw std::runtime_error("Select at least two languages");
|
throw std::runtime_error("Select at least two languages");
|
||||||
validateSettings(draft);
|
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.autoMinLength = draft.autoMinLength;
|
||||||
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
|
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
|
||||||
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
|
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);
|
DestroyWindow(w);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
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)
|
void showAutoOptions(HWND parent, SettingsView *v)
|
||||||
{
|
{
|
||||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic correction", autoOptionsProc,
|
||||||
560, 680);
|
560, 880);
|
||||||
|
windowLayoutState(w)->autoOptions = true;
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
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);
|
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"&English", 401, 18, 48, 150);
|
||||||
checkbox(w, L"Ukrainian", 402, 188, 48, 150);
|
checkbox(w, L"&Ukrainian", 402, 188, 48, 150);
|
||||||
checkbox(w, L"Russian", 403, 358, 48, 150);
|
checkbox(w, L"&Russian", 403, 358, 48, 150);
|
||||||
for (auto &lang : v->draft.autoLanguages)
|
for (auto &lang : v->draft.autoLanguages)
|
||||||
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
||||||
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
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"}})
|
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
||||||
{
|
{
|
||||||
int x = 18 + (id - 411) * 178;
|
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);
|
edit(w, id, x, 328, 168, 56, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||||
auto words = v->draft.autoWords.find(language);
|
auto words = v->draft.autoWords.find(language);
|
||||||
if (words != v->draft.autoWords.end()) value(w, id, commaList(words->second));
|
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, 408, v->draft.autoPrefixChars);
|
||||||
value(w, 409, v->draft.autoSuffixChars);
|
value(w, 409, v->draft.autoSuffixChars);
|
||||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
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);
|
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
||||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
check(w, 414, v->draft.autoReplace);
|
||||||
button(w, L"OK", IDOK, 326, 632, 100);
|
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
|
||||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
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);
|
EnableWindow(parent, FALSE);
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
@@ -1181,26 +1317,27 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
|||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
|
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);
|
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);
|
check(w, 501, v->draft.useTesseract);
|
||||||
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
|
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
|
||||||
edit(w, 502, 18, 118, 484);
|
edit(w, 502, 18, 118, 484);
|
||||||
value(w, 502, v->draft.tesseractPath);
|
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);
|
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
|
||||||
edit(w, 503, 18, 188, 602);
|
edit(w, 503, 18, 188, 602);
|
||||||
value(w, 503, v->draft.tesseractLanguages);
|
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);
|
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"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"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);
|
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"&OK", IDOK, 406, 390, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
|
button(w, L"&Cancel", IDCANCEL, 520, 390, 100);
|
||||||
EnableWindow(parent, FALSE);
|
EnableWindow(parent, FALSE);
|
||||||
showRequestedWindow(w); SetForegroundWindow(w);
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
}
|
}
|
||||||
|
#include "ui_sync.inl"
|
||||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (msg == WM_DPICHANGED)
|
if (msg == WM_DPICHANGED)
|
||||||
@@ -1237,6 +1374,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||||
|
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
collectLayout(w, v);
|
collectLayout(w, v);
|
||||||
@@ -1337,6 +1475,14 @@ struct SnippetView
|
|||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
int selection = -1;
|
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)
|
void fillSnippets(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
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());
|
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
||||||
if (v->selection >= 0)
|
if (v->selection >= 0)
|
||||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||||
|
updateSnippetEditor(w, v);
|
||||||
}
|
}
|
||||||
void collectSnippet(HWND w, SnippetView *v)
|
void collectSnippet(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
if (v->selection >= 0 && v->selection < (int)v->snippets.size())
|
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)
|
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);
|
collectSnippet(w, v);
|
||||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||||
if (v->selection >= 0)
|
updateSnippetEditor(w, v);
|
||||||
{
|
|
||||||
value(w, 302, v->snippets[v->selection].trigger);
|
|
||||||
value(w, 303, v->snippets[v->selection].text);
|
|
||||||
}
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 310)
|
if (id == 310)
|
||||||
{
|
{
|
||||||
collectSnippet(w, v);
|
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;
|
v->selection = (int)v->snippets.size() - 1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
value(w, 302, L";new");
|
|
||||||
value(w, 303, L"");
|
|
||||||
SetFocus(GetDlgItem(w, 302));
|
SetFocus(GetDlgItem(w, 302));
|
||||||
return 0;
|
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->snippets.erase(v->snippets.begin() + v->selection);
|
||||||
v->selection = -1;
|
v->selection = -1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
value(w, 302, L"");
|
SetFocus(GetDlgItem(w, 301));
|
||||||
value(w, 303, L"");
|
|
||||||
}
|
}
|
||||||
return 0;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == IDOK)
|
if (id == IDOK)
|
||||||
@@ -1501,18 +1645,19 @@ void showHistory(App &app, bool visible)
|
|||||||
app.historyWindow = w;
|
app.historyWindow = w;
|
||||||
windowLayoutState(w)->history = true;
|
windowLayoutState(w)->history = true;
|
||||||
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
((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);
|
edit(w, 101, 16, 38, 728);
|
||||||
auto list = control(w, L"LISTBOX", L"",
|
auto list = control(w, L"LISTBOX", L"",
|
||||||
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
||||||
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
||||||
102, 16, 76, 728, 360);
|
102, 16, 76, 728, 360);
|
||||||
SendMessageW(list, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
SendMessageW(list, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||||
button(w, L"Paste", 110, 16, 452, 90);
|
button(w, L"&Paste", 110, 16, 452, 90);
|
||||||
button(w, L"Plain text", 111, 114, 452, 100);
|
button(w, L"Plain &text", 111, 114, 452, 100);
|
||||||
button(w, L"Pin / unpin", 112, 222, 452, 110);
|
button(w, L"P&in / unpin", 112, 222, 452, 110);
|
||||||
button(w, L"Delete", 113, 340, 452, 90);
|
button(w, L"&Delete", 113, 340, 452, 90);
|
||||||
button(w, L"Open item", 114, 438, 452, 100);
|
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(list, historyKey, 1, (DWORD_PTR)w);
|
||||||
SetWindowSubclass(GetDlgItem(w, 101), 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));
|
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||||
refreshHistory(app);
|
refreshHistory(app);
|
||||||
}
|
}
|
||||||
namespace
|
#endif
|
||||||
{
|
#ifndef KS_RESIDENT_APP
|
||||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
#include "ui_log.inl"
|
||||||
LogClear = 606;
|
#else
|
||||||
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||||
{
|
|
||||||
// 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
|
#endif
|
||||||
#ifndef KS_RESIDENT_APP
|
#ifndef KS_RESIDENT_APP
|
||||||
void showSettings(App &app, bool visible)
|
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);
|
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660, 0, false);
|
||||||
app.settingsWindow = w;
|
app.settingsWindow = w;
|
||||||
label(w, L"Text conversion", 18, 16);
|
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"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"Switch Windows input &layout after conversion", 202, 18, 70, 390);
|
||||||
checkbox(w, L"Record clipboard history", 203, 18, 106);
|
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", 204, 18, 133);
|
||||||
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
|
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"&Keep screen awake", 205, 18, 184, 210);
|
||||||
checkbox(w, L"Write log", 208, 240, 184, 160);
|
checkbox(w, L"Write lo&g", 208, 240, 184, 160);
|
||||||
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
|
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",
|
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
|
||||||
L"Clipboard history", L"Screen capture"};
|
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);
|
label(w, names[i], 420, 48 + i * 30, 115);
|
||||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
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"Open editor after screen capt&ure", 209, 420, 224, 340);
|
||||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
checkbox(w, L"Automatic correction (e&xperimental)", 207, 18, 210, 395);
|
||||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
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,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||||
18, 262, 470, 172);
|
18, 262, 470, 172);
|
||||||
button(w, L"Move up", 232, 504, 262, 120);
|
button(w, L"Move u&p", 232, 504, 262, 120);
|
||||||
button(w, L"Move down", 233, 632, 262, 128);
|
button(w, L"Move &down", 233, 632, 262, 128);
|
||||||
checkbox(w, L"Include in cycle", 230, 504, 300, 240);
|
checkbox(w, L"&Include in cycle", 230, 504, 300, 240);
|
||||||
label(w, L"Direct layout shortcut", 504, 332, 240);
|
label(w, L"Direct layout shortcut", 504, 332, 240);
|
||||||
hotkeyEdit(app, w, 231, 504, 356, 256);
|
hotkeyEdit(app, w, 231, 504, 356, 256);
|
||||||
label(w, L"Backspace / Delete clears a shortcut", 504, 382, 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"History limits", 18, 444);
|
||||||
label(w, L"Items", 18, 478, 50);
|
label(w, L"Items", 18, 478, 50);
|
||||||
edit(w, 240, 70, 474, 90);
|
edit(w, 240, 70, 474, 90);
|
||||||
@@ -1825,17 +1730,18 @@ void showSettings(App &app, bool visible)
|
|||||||
edit(w, 241, 260, 474, 90);
|
edit(w, 241, 260, 474, 90);
|
||||||
label(w, L"Per item MiB", 370, 478, 100);
|
label(w, L"Per item MiB", 370, 478, 100);
|
||||||
edit(w, 242, 475, 474, 80);
|
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);
|
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
||||||
edit(w, 243, 18, 544, 742);
|
edit(w, 243, 18, 544, 742);
|
||||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
button(w, L"&Auto-correction options…", 255, 18, 575, 240);
|
||||||
button(w, L"View log…", 256, 278, 575, 220);
|
button(w, L"View l&og…", 256, 278, 575, 220);
|
||||||
button(w, L"OCR options…", 257, 518, 575, 242);
|
button(w, L"OCR op&tions…", 257, 518, 575, 242);
|
||||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
button(w, L"Impo&rt settings…", 250, 18, 606, 145);
|
||||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
button(w, L"&Export settings…", 251, 173, 606, 145);
|
||||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
button(w, L"S&nippets…", 252, 328, 606, 110);
|
||||||
button(w, L"Save", IDOK, 548, 606, 100);
|
button(w, L"S&ync...", 258, 448, 606, 90);
|
||||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
button(w, L"&Save", IDOK, 548, 606, 100);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 660, 606, 100);
|
||||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
if (visible)
|
if (visible)
|
||||||
{
|
{
|
||||||
@@ -1855,16 +1761,17 @@ void showSnippets(App &app)
|
|||||||
label(w, L"Triggers", 16, 16);
|
label(w, L"Triggers", 16, 16);
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
||||||
16, 44, 200, 380);
|
16, 44, 200, 380);
|
||||||
button(w, L"Add", 310, 16, 438, 90);
|
button(w, L"&Add", 310, 16, 438, 90);
|
||||||
button(w, L"Delete", 311, 116, 438, 100);
|
button(w, L"&Delete", 311, 116, 438, 100);
|
||||||
label(w, L"Trigger (for example ;addr)", 236, 16, 470);
|
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
||||||
edit(w, 302, 236, 44, 488);
|
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);
|
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488);
|
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
||||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
button(w, L"Insert cursor &marker", 312, 236, 430, 190);
|
||||||
button(w, L"Save", IDOK, 510, 478, 100);
|
button(w, L"Insert &Enter marker", 313, 436, 430, 190);
|
||||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
button(w, L"&Save", IDOK, 510, 478, 100);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 624, 478, 100);
|
||||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
@@ -1892,11 +1799,17 @@ void showEntry(App &app, const std::wstring &id)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto clip = app.history->load(id);
|
auto clip = app.history->load(id);
|
||||||
|
if(std::any_of(clip.formats.begin(),clip.formats.end(),[](const auto &format) {
|
||||||
|
return format.id==CF_DIB || format.id==CF_DIBV5 || format.name==L"PNG";
|
||||||
|
})) {
|
||||||
|
launchScreenshotEditor(app,clip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
auto text = clip.text();
|
auto text = clip.text();
|
||||||
if (text.empty())
|
if (text.empty())
|
||||||
{
|
{
|
||||||
MessageBoxW(app.historyWindow,
|
MessageBoxW(app.historyWindow,
|
||||||
L"This item contains an image or files. Use the preview or paste it into the "
|
L"This item contains files. Paste it into the "
|
||||||
L"destination application.",
|
L"destination application.",
|
||||||
L"Clipboard item", MB_OK);
|
L"Clipboard item", MB_OK);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.11";
|
inline constexpr wchar_t AppVersion[] = L"0.6.30";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
+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'
|
$ErrorActionPreference = 'Stop'
|
||||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
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", 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", 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 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 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")] 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);
|
[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 Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||||
function Window($class) {
|
function Window($class) {
|
||||||
for($i=0;$i -lt 100;$i++) {
|
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)
|
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||||
if($window -ne [IntPtr]::Zero){return $window}
|
if($window -ne [IntPtr]::Zero){return $window}
|
||||||
Start-Sleep -Milliseconds 50
|
Start-Sleep -Milliseconds 50
|
||||||
@@ -56,6 +57,24 @@ function Launch($file,$argument) {
|
|||||||
function Click($window,$id) {
|
function Click($window,$id) {
|
||||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
$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 {
|
try {
|
||||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||||
@@ -67,6 +86,13 @@ try {
|
|||||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||||
$resident=Launch 'KSwitcher.exe' ''
|
$resident=Launch 'KSwitcher.exe' ''
|
||||||
$main=Window 'KSwitcher.Main'
|
$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' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
[uint32]$ownerId=0
|
[uint32]$ownerId=0
|
||||||
@@ -102,6 +128,29 @@ try {
|
|||||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||||
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
||||||
$snippets=Window 'KSwitcher.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' ''
|
$second=Launch 'KSwitcher.Config.exe' ''
|
||||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
@@ -110,6 +159,7 @@ try {
|
|||||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||||
Click $snippets 2
|
Click $snippets 2
|
||||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||||
|
TestLogs
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$null=Window 'KSwitcher.Settings'
|
$null=Window 'KSwitcher.Settings'
|
||||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
|||||||
+176
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "ui_scroll.h"
|
||||||
#include "ui_geometry.h"
|
#include "ui_geometry.h"
|
||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -14,6 +15,37 @@ struct InputRegressionAccess {
|
|||||||
static inline bool delivery = true;
|
static inline bool delivery = true;
|
||||||
static inline int notifications = 0;
|
static inline int notifications = 0;
|
||||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
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 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 void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||||
static bool updateTimer(App &a) {
|
static bool updateTimer(App &a) {
|
||||||
@@ -567,6 +599,9 @@ int main(int argc, char **argv) {
|
|||||||
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
||||||
app.resetInput();
|
app.resetInput();
|
||||||
app.handleKeyboard(event('A',0x1e),true,0);
|
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");
|
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();
|
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)})
|
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
||||||
@@ -594,10 +629,137 @@ int main(int argc, char **argv) {
|
|||||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
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");
|
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||||
ks::showSettings(app,false);
|
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);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
require(autoOptions!=nullptr,"auto-correction options open");
|
require(autoOptions!=nullptr,"auto-correction options open");
|
||||||
ShowWindow(autoOptions,SW_HIDE);
|
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}) {
|
for(int id:{411,412,413}) {
|
||||||
RECT control{},client{};
|
RECT control{},client{};
|
||||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||||
@@ -607,6 +769,9 @@ int main(int argc, char **argv) {
|
|||||||
"personal dictionary field fits options window");
|
"personal dictionary field fits options window");
|
||||||
}
|
}
|
||||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
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);
|
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
@@ -616,6 +781,12 @@ int main(int argc, char **argv) {
|
|||||||
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
||||||
require(std::wstring(personalWords)==L"релізик, словечко",
|
require(std::wstring(personalWords)==L"релізик, словечко",
|
||||||
"personal dictionary normalizes case, newlines and duplicate words");
|
"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);
|
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
||||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||||
ks::showHistory(app,false);
|
ks::showHistory(app,false);
|
||||||
@@ -699,16 +870,21 @@ int main(int argc, char **argv) {
|
|||||||
controlMoves=0;
|
controlMoves=0;
|
||||||
ValidateRect(settings,nullptr);
|
ValidateRect(settings,nullptr);
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||||
|
require(controlMoves==0,"wheel changes wait for the shared frame timer");
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
require(controlMoves==1,"wheel scroll batches each control movement once");
|
||||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||||
controlMoves=0;
|
controlMoves=0;
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
||||||
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||||
|
|||||||
+443
-10
@@ -1,4 +1,6 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -7,6 +9,7 @@ namespace ks
|
|||||||
{
|
{
|
||||||
struct InputRegressionAccess
|
struct InputRegressionAccess
|
||||||
{
|
{
|
||||||
|
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||||
static bool deferring(App &app)
|
static bool deferring(App &app)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(app.stateMutex);
|
std::lock_guard lock(app.stateMutex);
|
||||||
@@ -20,6 +23,71 @@ ks::App *app{};
|
|||||||
HWND host{}, field{};
|
HWND host{}, field{};
|
||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
bool captured = false, failed = false;
|
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 == 3) { pasteBehavior = 0; return 0; }
|
||||||
|
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;
|
int stage = 0, ru = -1, en = -1;
|
||||||
size_t punctuationCase = 0;
|
size_t punctuationCase = 0;
|
||||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||||
@@ -36,13 +104,15 @@ std::string diagnosticLog()
|
|||||||
size_t diagnosticStart{};
|
size_t diagnosticStart{};
|
||||||
void require(bool ok, const char *name)
|
void require(bool ok, const char *name)
|
||||||
{
|
{
|
||||||
if (!ok)
|
if (!ok || clipboardAccessTimedOut)
|
||||||
{
|
{
|
||||||
wchar_t observed[2048]{};
|
wchar_t observed[2048]{};
|
||||||
GetWindowTextW(field, observed, 2048);
|
GetWindowTextW(field, observed, 2048);
|
||||||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||||||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||||||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
<< "; 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);
|
throw std::runtime_error(name);
|
||||||
}
|
}
|
||||||
std::cout << "PASS " << name << '\n';
|
std::cout << "PASS " << name << '\n';
|
||||||
@@ -94,6 +164,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
if (msg == WM_TIMER)
|
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
|
try
|
||||||
{
|
{
|
||||||
if (GetTickCount64() > deadline)
|
if (GetTickCount64() > deadline)
|
||||||
@@ -142,10 +215,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
require(text() == L"Hello World", "case conversion");
|
require(text() == L"Hello World", "case conversion");
|
||||||
|
droppedSelectionKeys = 5;
|
||||||
app->transform(ks::Case);
|
app->transform(ks::Case);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||||
|
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||||||
prepare(L"OLDghbdtn", false);
|
prepare(L"OLDghbdtn", false);
|
||||||
record(L"ghbdtn");
|
record(L"ghbdtn");
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -171,12 +246,20 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
"busy conversion attempt is logged");
|
"busy conversion attempt is logged");
|
||||||
require(diagnostics.find("repeat=3") != std::string::npos,
|
require(diagnostics.find("repeat=3") != std::string::npos,
|
||||||
"three repeated conversion attempts are logged");
|
"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");
|
"failed recent-word verification has explicit outcome");
|
||||||
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
|
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
|
||||||
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
|
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
|
||||||
"conversion diagnostics contain actual text and physical input for reproduction");
|
"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"}};
|
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
||||||
prepare(L"prefix ;addr", false);
|
prepare(L"prefix ;addr", false);
|
||||||
record(L";addr");
|
record(L";addr");
|
||||||
@@ -234,6 +317,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
DestroyWindow(field);
|
DestroyWindow(field);
|
||||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
||||||
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
prepare(L"ghbdtn ", false);
|
prepare(L"ghbdtn ", false);
|
||||||
record(L"ghbdtn ");
|
record(L"ghbdtn ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
@@ -245,6 +329,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
DestroyWindow(field);
|
DestroyWindow(field);
|
||||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
||||||
nullptr, GetModuleHandleW(nullptr), nullptr);
|
nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
prepare(L"(ljvf ", false);
|
prepare(L"(ljvf ", false);
|
||||||
record(L"(ljvf ");
|
record(L"(ljvf ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
@@ -280,7 +365,205 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 17:
|
case 17:
|
||||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
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");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
|
||||||
|
pasteBehavior = 2;
|
||||||
|
prepare(L"ghbdtn", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
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");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
|
||||||
|
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");
|
||||||
|
pasteBehavior = 3;
|
||||||
|
prepare(L"=nhtdjuf", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
stage = 49;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 49:
|
||||||
|
{
|
||||||
|
require(text() == L"=тревога" && pasteBehavior == 0,
|
||||||
|
"native Edit retries one ignored paste only while original remains selected");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard");
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 30:
|
||||||
|
case 32:
|
||||||
|
case 34:
|
||||||
|
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:
|
case 20:
|
||||||
if (!captured)
|
if (!captured)
|
||||||
{
|
{
|
||||||
@@ -315,7 +598,99 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 19:
|
case 19:
|
||||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||||
"rejected correction collapses selection before replaying typing in original layout");
|
"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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +713,8 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
} // namespace
|
} // namespace
|
||||||
int main(int argc, char **argv)
|
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);
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
auto root = std::filesystem::temp_directory_path() /
|
auto root = std::filesystem::temp_directory_path() /
|
||||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
@@ -353,9 +730,22 @@ int main(int argc, char **argv)
|
|||||||
ks::showLogs(instance);
|
ks::showLogs(instance);
|
||||||
HWND viewer = instance.logWindow;
|
HWND viewer = instance.logWindow;
|
||||||
HWND rows = GetDlgItem(viewer, 602);
|
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,
|
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
||||||
"log viewer uses report ListView");
|
"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");
|
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
|
||||||
SetFocus(rows);
|
SetFocus(rows);
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
||||||
@@ -365,10 +755,11 @@ int main(int argc, char **argv)
|
|||||||
ks::showLogs(instance);
|
ks::showLogs(instance);
|
||||||
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
|
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);
|
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
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")
|
if (argc > 2 && std::string(argv[2]) == "--clear-test")
|
||||||
{
|
{
|
||||||
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
||||||
@@ -393,7 +784,46 @@ int main(int argc, char **argv)
|
|||||||
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
||||||
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
||||||
"logging resumes after clear without stale context");
|
"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);
|
auto savedClipboard = ks::readClipboard(viewer);
|
||||||
instance.window = viewer;
|
instance.window = viewer;
|
||||||
try
|
try
|
||||||
@@ -439,8 +869,11 @@ int main(int argc, char **argv)
|
|||||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
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;
|
stage = 20;
|
||||||
|
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||||
|
stage = 30;
|
||||||
SetTimer(host, 1, 100, nullptr);
|
SetTimer(host, 1, 100, nullptr);
|
||||||
app->execute();
|
app->execute();
|
||||||
}
|
}
|
||||||
|
|||||||
+374
-2
@@ -2,10 +2,69 @@
|
|||||||
// Including the implementation lets this integration test drive private editor state directly.
|
// Including the implementation lets this integration test drive private editor state directly.
|
||||||
#include "../src/screenshot.cpp"
|
#include "../src/screenshot.cpp"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
int main()
|
#include "stroke_improvement.h"
|
||||||
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
try {
|
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);
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||||
@@ -24,6 +83,319 @@ int main()
|
|||||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||||
"new screenshot editor starts with the pencil tool");
|
"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}) {
|
for(int size:{32,48,64,96}) {
|
||||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||||
ICONINFO info{};
|
ICONINFO info{};
|
||||||
@@ -76,7 +448,7 @@ int main()
|
|||||||
}
|
}
|
||||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
||||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
|
require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
|
||||||
RECT client{}; GetClientRect(window,&client);
|
RECT client{}; GetClientRect(window,&client);
|
||||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||||
ks::updateStatus(c);
|
ks::updateStatus(c);
|
||||||
|
|||||||
@@ -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 "autocorrect.h"
|
||||||
|
#include "history_preview.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -21,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
|
|||||||
bool thrown = false;
|
bool thrown = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
{
|
||||||
|
std::map<std::wstring,int> previews;
|
||||||
|
std::set<std::wstring> visible;
|
||||||
|
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
||||||
|
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"preview cache stays bounded");
|
||||||
|
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
||||||
|
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
||||||
|
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
||||||
|
}
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
@@ -44,6 +60,34 @@ int main(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
try
|
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",
|
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
|
||||||
"diagnostic word preserved");
|
"diagnostic word preserved");
|
||||||
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
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,
|
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
|
||||||
"context expires after thirty seconds");
|
"context expires after thirty seconds");
|
||||||
diagnostics.record(L"oldest-context", false, 30600);
|
diagnostics.record(L"oldest-context", false, 30600);
|
||||||
for (int i = 0; i < 40; ++i)
|
for (int i = 0; i < 128; ++i)
|
||||||
diagnostics.record(L"bounded-context", false, 30600 + i);
|
diagnostics.record(L"bounded-context", false, 30600);
|
||||||
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
||||||
"context buffer has a fixed size");
|
"context buffer has a fixed size");
|
||||||
diagnostics.record(L"disabled-context", false, 30800);
|
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"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||||
|
ks::Run suffixRun;
|
||||||
|
suffixRun.keys = L"snipping ";
|
||||||
|
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
|
||||||
|
"recent Cyrillic suffix matches physical keys across layouts");
|
||||||
|
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
|
||||||
|
"recent English suffix matches physical keys");
|
||||||
|
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
|
||||||
|
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
|
||||||
|
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
|
||||||
|
"suffix recovery rejects unrelated, empty and complete ranges");
|
||||||
|
suffixRun.overflow = true;
|
||||||
|
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
|
||||||
|
"suffix recovery rejects overflowed tracking");
|
||||||
|
suffixRun.overflow = false;
|
||||||
|
suffixRun.keys = L"LinkedIn ";
|
||||||
|
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
|
||||||
|
"ChatGPT partial CamelCase selection matches tracked physical suffix");
|
||||||
|
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
|
||||||
|
suffixRun.match(L"лувШт ", layouts, en) < 0,
|
||||||
|
"CamelCase conversion still requires the complete tracked text after recovery");
|
||||||
ks::AutoCorrector corrector;
|
ks::AutoCorrector corrector;
|
||||||
ks::Settings autoSettings;
|
ks::Settings autoSettings;
|
||||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||||
autoSettings.cycle = {L"en", L"uk"};
|
autoSettings.cycle = {L"en", L"uk"};
|
||||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
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);
|
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
||||||
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
||||||
"dictionary corrects Ukrainian and retains trailing space");
|
"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.autoLanguages = {L"en", L"uk", L"ru"};
|
||||||
options.cycle = {L"en", L"uk", L"ru"};
|
options.cycle = {L"en", L"uk", L"ru"};
|
||||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||||
|
auto current = supplemental.suggest(L"Gjnjxys ", 0, layouts, options);
|
||||||
|
require(current && current->text == L"Поточні " && current->target == 2,
|
||||||
|
"reported title-case Ukrainian word corrects with all three dictionaries enabled");
|
||||||
|
current = supplemental.suggest(L"Поточны ", 1, layouts, options);
|
||||||
|
require(current && current->text == L"Поточні " && current->target == 2,
|
||||||
|
"reported Ukrainian word corrects from Russian layout");
|
||||||
|
require(!supplemental.suggest(L"Поточні ", 2, layouts, options),
|
||||||
|
"correct Ukrainian word remains unchanged");
|
||||||
|
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||||
|
{
|
||||||
|
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);
|
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
||||||
require(release && release->text == L"реліз " && release->target == 2,
|
require(release && release->text == L"реліз " && release->target == 2,
|
||||||
"supplemental Ukrainian release word corrects from Russian layout");
|
"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),
|
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
|
||||||
"valid Russian release word stays unchanged");
|
"valid Russian release word stays unchanged");
|
||||||
options.autoMaxRank = 50000;
|
options.autoMaxRank = 50000;
|
||||||
|
require(!supplemental.suggest(L"Gjnjxys ", 0, layouts, options),
|
||||||
|
"supplemental current word respects explicit frequency limit");
|
||||||
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
||||||
"supplemental vocabulary respects explicit frequency limit");
|
"supplemental vocabulary respects explicit frequency limit");
|
||||||
options.autoWords[L"uk"] = {L"релізик"};
|
options.autoWords[L"uk"] = {L"релізик"};
|
||||||
@@ -268,6 +357,15 @@ int main(int argc, char **argv)
|
|||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
"never-correct list takes precedence over personal word");
|
"never-correct list takes precedence over personal word");
|
||||||
options.autoNever.clear();
|
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"};
|
options.cycle = {L"en", L"ru"};
|
||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
"personal dictionary respects disabled target layouts");
|
"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"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
|
||||||
|
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
|
||||||
|
"cycle skips unchanged Russian to Ukrainian and wraps to English");
|
||||||
|
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
|
||||||
|
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
|
||||||
|
"cycle skips unchanged Ukrainian to Russian");
|
||||||
|
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
|
||||||
|
"cycle keeps the next language when text changes");
|
||||||
|
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
|
||||||
|
"cycle preserves configured order for changed text");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
|
||||||
|
"cycle does not use disabled English when all candidates are unchanged");
|
||||||
|
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
|
||||||
|
"unchanged digits finish after a bounded cycle");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
|
||||||
|
"source outside cycle still skips unchanged candidates");
|
||||||
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
||||||
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
||||||
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
||||||
@@ -284,6 +398,12 @@ int main(int argc, char **argv)
|
|||||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
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"{{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::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+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
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);
|
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
||||||
require(correction && correction->text == L"hello ",
|
require(correction && correction->text == L"hello ",
|
||||||
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
"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::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}}}};
|
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.writeLog = false;
|
||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
|
settings.autoReplace = settings.autoTypos = true;
|
||||||
|
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
||||||
settings.autoMaxRank = 10000;
|
settings.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
@@ -441,6 +602,14 @@ int main(int argc, char **argv)
|
|||||||
ks::saveSettings(temp / L"settings.json", settings);
|
ks::saveSettings(temp / L"settings.json", settings);
|
||||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||||
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
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;
|
auto invalidWords = settings;
|
||||||
invalidWords.autoWords[L"uk"] = {L"hello"};
|
invalidWords.autoWords[L"uk"] = {L"hello"};
|
||||||
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ try {
|
|||||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
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 ($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 ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
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 ($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'
|
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}
|
$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'
|
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}
|
$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'
|
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import urllib.request, hashlib, json
|
|||||||
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
||||||
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
||||||
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
|
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
|
||||||
SUPPLEMENTAL={'uk': {'реліз': 50001}}
|
SUPPLEMENTAL={'uk': {'реліз': 50001, 'поточні': 50001}}
|
||||||
ROOT.mkdir(exist_ok=True)
|
ROOT.mkdir(exist_ok=True)
|
||||||
manifest={}
|
manifest={}
|
||||||
for lang in ('en','uk','ru'):
|
for lang in ('en','uk','ru'):
|
||||||
|
|||||||
Reference in New Issue
Block a user