Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc |
+7
-2
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.19 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.24 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()
|
||||||
@@ -17,6 +17,8 @@ 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(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 +41,7 @@ endif()
|
|||||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
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 +51,17 @@ 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 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 .)
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ broker can load them even when the profile directory is inaccessible to it.
|
|||||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
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 +111,10 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
|||||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
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,7 +162,34 @@ 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, light smoothing reduces 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. Hold **Shift** while drawing to constrain the current segment
|
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.
|
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||||
@@ -168,7 +199,7 @@ released repeatedly within one stroke. The **Line style** selector supports Soli
|
|||||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
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**.
|
||||||
@@ -220,6 +251,10 @@ 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.
|
||||||
@@ -278,6 +313,12 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
|||||||
|
|
||||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
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.
|
||||||
|
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
|
||||||
@@ -379,6 +420,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.
|
||||||
|
|||||||
Binary file not shown.
+10
-8
@@ -1,21 +1,23 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.19 — 2026-10-31
|
## v0.6.24 — 2026-11-01
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Retry a silently dropped Ctrl+C up to three total attempts, only while the transaction clipboard remains unchanged and the original input context is still valid. Failed snippet verification preserves the trigger and following input without sending Enter.
|
- Smooth pencil strokes and recognize rectangles, circles, ellipses, triangles and trapezoids, including tilted contours. Ctrl+Z restores the original stroke; Ctrl+Y reapplies the improvement.
|
||||||
- Exclude temporary transaction clipboard contents from Windows clipboard history and cloud synchronization. The original clipboard snapshot is still restored after the operation when ownership permits.
|
- Recognized outlines become editable shapes. Add Triangle (G) and Trapezoid (B) toolbar tools; triangles have three independently draggable vertices.
|
||||||
- Fix snippet integration test isolation: restore a multiline editor after earlier single-line scenarios, reset layout/correction state, coordinate test-editor clipboard access and disable unrelated background update checks.
|
- Rotate all shapes with their round handle, with Shift snapping to 15-degree steps. Resizing, cloning, connector attachments and PNG export follow shape rotation.
|
||||||
- Add deterministic coverage for one dropped copy and exhausted retries. Keep exact assertions for Enter ordering, buffered typing, clipboard restoration and subsequent auto-correction.
|
- Zoom with Ctrl++ / Ctrl+- or Ctrl+mouse wheel, reset with Ctrl+0 and pan with middle-button dragging. Zooming does not change image resolution or undo history.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
The fixes passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks and updater progress checks. The full editor integration suite passed all 63 assertions; conversion-specific diagnostics also passed. The final snippet suite passed 20 consecutive runs, including injected copy failures and the three-attempt limit. The v0.6.19 staged Release build also passed the native, input/UI, screenshot and updater checks; the installed application was not replaced or restarted.
|
423 native regressions, 473 input/UI checks, shared wheel handling, screenshot editor and shape/zoom regressions, PNG save regressions, notification registration, 48 updater checks and updater progress checks passed.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
The fix does not retry paste blindly: an editor may already have changed its selection or text. Clipboard contention, editor capabilities and input privileges can still prevent an operation. Existing accessibility readback limitations and problem-only diagnostic logging remain unchanged.
|
Shape recognition is heuristic: ambiguous outlines may remain smoothed strokes or require Ctrl+Z to restore the original. Small, linked and Shift-constrained pencil strokes are not automatically improved. Triangle vertices are edited independently; trapezoids use bounding-box resize handles. Canvas zoom is limited to 0.1×–16× the initial fit. PNG exports are flattened images, not editable projects.
|
||||||
|
|
||||||
|
Existing browser-selection verification and clipboard/accessibility limitations remain unchanged. The bundled Ukrainian dictionary includes `поточні`; updates preserve existing dictionaries. If missing, add it under Settings → Auto-correction options → My dictionary → Ukrainian, then OK → Save.
|
||||||
|
|
||||||
**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 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip
|
5976c1363e4342830f503cba6d17b3ec1386741f636932117d9b08f55004af92 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ $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')
|
||||||
@@ -39,6 +41,10 @@ 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/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||||
|
& .\build\KSwitcherScrollTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/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
|
||||||
@@ -51,6 +57,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
|
||||||
|
|||||||
@@ -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,19,0
|
FILEVERSION 0,6,24,0
|
||||||
PRODUCTVERSION 0,6,19,0
|
PRODUCTVERSION 0,6,24,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.19"
|
VALUE "FileVersion", "0.6.24"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+27
-9
@@ -227,7 +227,8 @@ 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.
|
||||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||||
@@ -247,7 +248,25 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
return select(count) ? copy() : std::wstring{};
|
auto text = select(count) ? copy() : std::wstring{};
|
||||||
|
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||||
|
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||||
|
if (valid() && !matches(text) && !text.empty() &&
|
||||||
|
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
|
||||||
|
caretSteps(text) < count)
|
||||||
|
{
|
||||||
|
const auto missing = count - caretSteps(text);
|
||||||
|
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
|
||||||
|
for (size_t i = 0; i < missing; ++i)
|
||||||
|
{
|
||||||
|
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||||
|
return {};
|
||||||
|
Sleep(25);
|
||||||
|
}
|
||||||
|
text = copy();
|
||||||
|
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
bool paste(const Clip &clip)
|
bool paste(const Clip &clip)
|
||||||
{
|
{
|
||||||
@@ -433,7 +452,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);
|
||||||
@@ -1138,7 +1157,9 @@ 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));
|
||||||
@@ -1155,6 +1176,7 @@ 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); });
|
||||||
@@ -1202,11 +1224,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,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 &);
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -459,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)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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
|
||||||
@@ -92,5 +94,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,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 &);
|
||||||
|
|||||||
+3
-2
@@ -396,13 +396,14 @@ 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();
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
+349
-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;
|
||||||
@@ -179,12 +182,18 @@ struct Capture
|
|||||||
COLORREF customColors[16]{};
|
COLORREF customColors[16]{};
|
||||||
Mark stroke{};
|
Mark stroke{};
|
||||||
bool strokeConstrained{};
|
bool strokeConstrained{};
|
||||||
|
bool strokeUsedConstraint{};
|
||||||
size_t strokeAnchor{};
|
size_t strokeAnchor{};
|
||||||
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
||||||
bool strokeAxisSettled{};
|
bool strokeAxisSettled{};
|
||||||
std::vector<POINT> strokeMotion;
|
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{};
|
||||||
@@ -208,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);
|
||||||
@@ -239,11 +249,13 @@ void updateStatus(Capture &c)
|
|||||||
if(c.hoveredTool==30) text=L"Line style: solid, dotted, dashed or dash-dot";
|
if(c.hoveredTool==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];
|
||||||
@@ -255,9 +267,11 @@ void updateStatus(Capture &c)
|
|||||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
else if(c.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): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
|
else if(c.freehand) text=L"Freehand (F): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | Ctrl = draw over objects";
|
||||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
else 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());
|
||||||
@@ -297,6 +311,12 @@ void positionInlineText(Capture &c)
|
|||||||
int width=std::min(std::max(120,MulDiv(bounds.right-bounds.left,viewWidth,c.image->width)+24),std::max(32,int(client.right)-x));
|
int 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)
|
||||||
{
|
{
|
||||||
@@ -337,6 +357,14 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
|||||||
}
|
}
|
||||||
LRESULT CALLBACK inlineTextProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
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;
|
||||||
}
|
}
|
||||||
@@ -366,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);
|
||||||
@@ -380,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;
|
||||||
@@ -484,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);
|
||||||
@@ -599,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)
|
||||||
{
|
{
|
||||||
@@ -607,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);
|
||||||
@@ -616,13 +688,35 @@ void layout(HWND w, Capture &c)
|
|||||||
MoveWindow(GetDlgItem(w,34),MulDiv(480,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(28,dpi,96),MulDiv(28,dpi,96),TRUE);
|
MoveWindow(GetDlgItem(w,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)),
|
||||||
@@ -650,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));
|
||||||
@@ -673,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);
|
||||||
@@ -684,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];
|
||||||
@@ -699,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));
|
||||||
@@ -755,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);
|
||||||
@@ -771,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;
|
||||||
@@ -785,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;
|
||||||
@@ -821,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);
|
||||||
@@ -841,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); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
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)
|
||||||
@@ -938,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));
|
||||||
@@ -1023,19 +1204,22 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
if(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;
|
||||||
@@ -1046,12 +1230,15 @@ 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) {
|
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
|
||||||
c->strokeConstrained=true;
|
c->strokeConstrained=true;
|
||||||
|
c->strokeUsedConstraint=true;
|
||||||
c->strokeAnchor=c->stroke.points.size()-1;
|
c->strokeAnchor=c->stroke.points.size()-1;
|
||||||
c->strokeAxis=0;
|
c->strokeAxis=0;
|
||||||
c->strokeAxisSettled=false;
|
c->strokeAxisSettled=false;
|
||||||
@@ -1063,6 +1250,12 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
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];
|
||||||
@@ -1079,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)
|
||||||
@@ -1094,6 +1290,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
if(wp=='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);
|
||||||
@@ -1118,7 +1316,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
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);
|
||||||
@@ -1128,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)
|
||||||
@@ -1172,6 +1372,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
c->stroke.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->strokeConstrained=false;
|
||||||
|
c->strokeUsedConstraint=(wp&MK_SHIFT)!=0;
|
||||||
}
|
}
|
||||||
c->dragging=true; SetCapture(w);
|
c->dragging=true; SetCapture(w);
|
||||||
}
|
}
|
||||||
@@ -1179,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; }
|
||||||
@@ -1200,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)};
|
||||||
@@ -1210,6 +1439,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
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));
|
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
|
||||||
|
c->strokeUsedConstraint=c->strokeUsedConstraint || constrained;
|
||||||
if(constrained) {
|
if(constrained) {
|
||||||
if(!c->strokeConstrained) {
|
if(!c->strokeConstrained) {
|
||||||
c->strokeAnchor=c->stroke.points.size()-1;
|
c->strokeAnchor=c->stroke.points.size()-1;
|
||||||
@@ -1303,6 +1533,23 @@ 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{};
|
||||||
|
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized);
|
||||||
|
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(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)
|
||||||
@@ -1313,6 +1560,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);
|
||||||
@@ -1325,6 +1573,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;
|
||||||
@@ -1334,7 +1583,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;
|
||||||
@@ -1384,7 +1633,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));
|
||||||
}
|
}
|
||||||
@@ -1402,9 +1651,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1449,6 +1701,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};
|
||||||
@@ -1528,23 +1785,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;
|
||||||
@@ -1564,6 +1821,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) {
|
||||||
@@ -1648,6 +1906,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);
|
||||||
@@ -1658,8 +1923,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) {
|
||||||
@@ -1713,7 +1980,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);
|
||||||
@@ -1767,13 +2059,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 &);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#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) {
|
||||||
|
if(shape) *shape=ScreenshotShape::None;
|
||||||
|
using namespace stroke_detail;
|
||||||
|
if(input.size()<5) 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()<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-11
@@ -1,5 +1,7 @@
|
|||||||
#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"
|
||||||
@@ -238,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}});
|
||||||
@@ -368,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);
|
||||||
@@ -478,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);
|
||||||
@@ -507,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)
|
||||||
@@ -690,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);
|
||||||
@@ -702,6 +725,7 @@ 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)
|
||||||
@@ -747,10 +771,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];
|
||||||
@@ -758,8 +785,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;
|
||||||
@@ -770,16 +800,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:
|
||||||
@@ -798,15 +837,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"Pin / 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)
|
||||||
@@ -1700,11 +1753,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.19";
|
inline constexpr wchar_t AppVersion[] = L"0.6.24";
|
||||||
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -728,16 +729,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);
|
||||||
|
|||||||
@@ -26,10 +26,17 @@ bool conversionOnly = false;
|
|||||||
int pasteBehavior = 0;
|
int pasteBehavior = 0;
|
||||||
int enterPresses = 0;
|
int enterPresses = 0;
|
||||||
int copyFailuresRemaining = 0, copyRequests = 0;
|
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||||
|
int droppedSelectionKeys = 0;
|
||||||
bool temporaryClipboardExcluded = true;
|
bool temporaryClipboardExcluded = true;
|
||||||
bool clipboardAccessTimedOut = false;
|
bool clipboardAccessTimedOut = false;
|
||||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
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
|
// This bare EDIT control is our test fixture, not a full editor with
|
||||||
// clipboard retries. Keep clipboard access on its owning UI thread while
|
// clipboard retries. Keep clipboard access on its owning UI thread while
|
||||||
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
||||||
@@ -205,10 +212,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);
|
||||||
@@ -392,6 +401,32 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
||||||
"disabled diagnostics preserves conversion and writes no records");
|
"disabled diagnostics preserves conversion and writes no records");
|
||||||
ks::setLoggingEnabled(true);
|
ks::setLoggingEnabled(true);
|
||||||
|
prepare(L"PuntoSwitcher ", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 44:
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 45:
|
||||||
|
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||||
|
droppedSelectionKeys = 5;
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 46:
|
||||||
|
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||||
|
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||||
|
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel,
|
||||||
|
"suffix recovery restores original clipboard");
|
||||||
|
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||||
|
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||||
|
record(L"snipping ");
|
||||||
|
droppedSelectionKeys = 4;
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 47:
|
||||||
|
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||||
|
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
|
|||||||
+269
-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,169 @@ 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);
|
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||||
@@ -32,6 +254,51 @@ int main()
|
|||||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
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));
|
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");
|
||||||
|
auto improved=c.marks.back().points;
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks.size()==1 && 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().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_LBUTTONDOWN,{40,40},true);
|
||||||
mouse(WM_MOUSEMOVE,{100,50},true);
|
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||||
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||||
@@ -175,7 +442,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,96 @@
|
|||||||
|
#pragma once
|
||||||
|
inline void testStrokeImprovement() {
|
||||||
|
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#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"
|
||||||
@@ -22,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 (...)
|
||||||
@@ -177,6 +192,26 @@ int main(int argc, char **argv)
|
|||||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
require(ks::convert(L"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");
|
||||||
@@ -276,6 +311,14 @@ 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"мало "}})
|
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||||
{
|
{
|
||||||
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||||
@@ -292,6 +335,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"релізик"};
|
||||||
@@ -322,6 +367,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");
|
||||||
|
|||||||
@@ -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