Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c786e12a9 | |||
| 107d2f8852 | |||
| 95c7199027 | |||
| 8fedb54dfe | |||
| bbf20e8270 | |||
| b8bb14a985 | |||
| d28e28c03c | |||
| 6b180798f5 | |||
| 21a09e33be | |||
| 3dbd7317f1 | |||
| 510d0ed211 | |||
| dd9629f8fd |
+7
-7
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.5.0 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.9 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -8,8 +8,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
@@ -18,15 +18,15 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/app.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)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherInputTests tests/input.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(KSwitcherInputTests PRIVATE kscore)
|
||||
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
|
||||
target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot.cpp resources/app.rc)
|
||||
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc)
|
||||
set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot")
|
||||
target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||
target_link_libraries(KSwitcherScreenshot PRIVATE kscore)
|
||||
@@ -36,7 +36,7 @@ if(MSVC)
|
||||
else()
|
||||
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp)
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
|
||||
@@ -4,7 +4,11 @@ Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://git
|
||||
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
||||
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
||||
|
||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
|
||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
|
||||
|
||||
[](https://send.monobank.ua/jar/88h2TddKfS)
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -71,9 +75,29 @@ physical key positions and therefore reset recent-word tracking; selection conve
|
||||
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
||||
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
||||
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
||||
The editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately,
|
||||
or annotate first: **N** places sequential numbered red markers with a click, and **A** draws
|
||||
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
|
||||
The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
|
||||
is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
|
||||
to open it automatically; this option is off by default. An immediate notification error still opens the editor.
|
||||
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
|
||||
notification center without showing a banner.
|
||||
The preference applies to each new capture, including while other editors are open.
|
||||
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
|
||||
at resident startup and refreshed before sending; registration/submission errors are written to the log.
|
||||
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
|
||||
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
|
||||
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
|
||||
broker can load them even when the profile directory is inaccessible to it.
|
||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
||||
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
|
||||
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
|
||||
cursors scale with display DPI and use a contrasting outline for light and dark images.
|
||||
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
||||
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
|
||||
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
||||
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
||||
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
||||
@@ -83,6 +107,14 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
|
||||
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
||||
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
||||
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
|
||||
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
||||
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
||||
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
||||
leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected.
|
||||
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
||||
and its keyboard shortcut.
|
||||
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
||||
@@ -111,6 +143,8 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
|
||||
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
||||
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
||||
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
||||
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
|
||||
is reused; rotation belongs to each individual object.
|
||||
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
||||
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
||||
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
||||
@@ -142,7 +176,14 @@ Captures use ordinary clipboard history settings and limits. Protected video and
|
||||
|
||||
Select **Update** in the tray menu to check the stable Releases of
|
||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||
A startup check also reports an available version, but never installs it automatically.
|
||||
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
||||
It reports each available version once per session and never installs automatically.
|
||||
Update availability uses the same Windows toast notification channel as screenshot notifications.
|
||||
Click the notification or **Review update** to check the latest release and confirm installation;
|
||||
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
|
||||
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||
Confirm the offered version to download and install it. The updater uses the existing
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||
@@ -161,6 +202,8 @@ controls and fonts. Settings has a fixed form size, without manual resizing or m
|
||||
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
||||
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||
field into view; Shift+wheel scrolls horizontally.
|
||||
Clipboard history also updates its preview row heights when DPI changes, keeping text,
|
||||
thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
|
||||
## Implemented
|
||||
|
||||
@@ -185,16 +228,33 @@ field into view; Shift+wheel scrolls horizontally.
|
||||
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
|
||||
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
|
||||
metadata (including Snipping Tool's repeated clipboard publication).
|
||||
- Windows OCR, entirely native and offline. Right-click an image in history and select
|
||||
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
|
||||
- New images are recognized automatically in a separate background worker using native, offline
|
||||
Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search,
|
||||
without changing the image or clipboard. Completed results (including empty text) are reused;
|
||||
pending work resumes after restart. Older images without OCR are processed once in the background,
|
||||
starting with the newest entries.
|
||||
Right-click an image and select **Copy text from image** to copy the saved text or explicitly
|
||||
retry recognition. Requires a Windows OCR language installed for the user profile; automatic
|
||||
failures are logged quietly. OCR sidecars count toward the history size limit and are removed
|
||||
with their images.
|
||||
- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains
|
||||
the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package
|
||||
separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable
|
||||
path blank to detect a standard installation, or select `tesseract.exe`; its neighboring
|
||||
`libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation**
|
||||
validates the selected models. **Download Tesseract…** opens the Windows installer page in your
|
||||
browser. No packages or models are downloaded automatically.
|
||||
Tesseract processes screenshots locally in memory with a 30-second recognition deadline.
|
||||
Changing engine, executable path or languages refreshes cached image text in the background.
|
||||
- History exclusion flags and excluded processes, pause/resume, count/size limits.
|
||||
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
|
||||
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
|
||||
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
||||
|
||||
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
|
||||
Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||
Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
|
||||
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
+14
-22
@@ -1,35 +1,27 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.4.1 — 2026-10-28
|
||||
## v0.6.9 — 2026-10-29
|
||||
|
||||
- Fix Settings spacing around Screen capture, automatic layout correction and layout controls.
|
||||
- Verify update checksums through .NET SHA-256 without requiring the PowerShell Get-FileHash command. This fixes updates from processes launched with a conflicting PowerShell module environment.
|
||||
- Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows.
|
||||
- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text.
|
||||
- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions.
|
||||
- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps.
|
||||
- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps.
|
||||
- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor.
|
||||
- Preserve the separate on-demand configurator and configurable capture shortcut.
|
||||
## Changes
|
||||
- Show automatic update announcements using the same native Windows notification channel as screenshots. Review update opens the update check and confirmation; Later dismisses the notification.
|
||||
- Retry notification delivery after five minutes if Windows rejects it, and log update checks and delivery results. Successfully submitted notifications are deduplicated for the current version during the running session.
|
||||
- Fix Settings flicker when using the mouse wheel: skip layout work at scroll boundaries, move only changed controls in one batch, and avoid erasing the entire window background.
|
||||
|
||||
Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks,
|
||||
29 updater checks, including missing Get-FileHash coverage, and package validation.
|
||||
## Validation
|
||||
203 native checks, 456 input/layout checks, screenshot editor UI integration, isolated notification registration and activation checks, and 45 updater checks. Regression coverage includes notification retry/deduplication, review-only activation, partial wheel steps, scroll boundaries and batched control movement.
|
||||
|
||||
**Upgrade from v0.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported.
|
||||
If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer
|
||||
(outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency.
|
||||
**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE.
|
||||
Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
**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.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables,
|
||||
offline dictionaries, documentation and license notices; no personal settings or clipboard history.
|
||||
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.
|
||||
|
||||
Windows notification banners follow system notification and Do not disturb settings. The new update notification behavior takes effect after installing v0.6.9.
|
||||
|
||||
Build and package:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test
|
||||
.\build.ps1 -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the archive, checksum and sources together. Publish both assets on a stable release whose
|
||||
tag matches the executable version.
|
||||
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
|
||||
@@ -1 +1 @@
|
||||
7b9f07168023d2ad5c71672d0e699144bc15b11175eb05a7149b72b8be4f90b4 KSwitcher-win-x64.zip
|
||||
cd6b3dd62065e50b68cf77344fbad8d7be41467142f31527933342f00e3c0f63 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -13,8 +13,8 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||
}
|
||||
@@ -27,7 +27,7 @@ try {
|
||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||
@@ -35,7 +35,7 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/screenshot.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
@@ -43,25 +43,33 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||
& .\build\KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||
try {
|
||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
||||
& $registrationTest
|
||||
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
||||
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
|
||||
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||
& .\build\KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||
}
|
||||
if ($Ocr) {
|
||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||
& .\build\KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,5,0,0
|
||||
PRODUCTVERSION 0,5,0,0
|
||||
FILEVERSION 0,6,9,0
|
||||
PRODUCTVERSION 0,6,9,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.5.0"
|
||||
VALUE "FileVersion", "0.6.9"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g font-family="Segoe UI" fill="#241B46">
|
||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
|
||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
|
||||
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
|
||||
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
|
||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
|
||||
</g>
|
||||
<g transform="translate(759 48) rotate(10 66 66)">
|
||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||
</g>
|
||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g font-family="Segoe UI" fill="#241B46">
|
||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
|
||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
|
||||
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
|
||||
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
|
||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
|
||||
</g>
|
||||
<g transform="translate(759 48) rotate(10 66 66)">
|
||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||
</g>
|
||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
+116
-10
@@ -2,6 +2,7 @@
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "configuration.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <set>
|
||||
@@ -232,6 +233,7 @@ class Transaction
|
||||
} // namespace
|
||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||
{
|
||||
updateNotifier = notifyUpdateAvailable;
|
||||
current = this;
|
||||
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
||||
std::filesystem::create_directories(data);
|
||||
@@ -268,8 +270,15 @@ App::~App()
|
||||
std::lock_guard lock(jobsMutex);
|
||||
stop = true;
|
||||
jobs.clear();
|
||||
pendingHistorySearch = {};
|
||||
}
|
||||
jobsReady.notify_all();
|
||||
historySearchReady.notify_all();
|
||||
historyOcrReady.notify_all();
|
||||
if (historyOcrWorker.joinable())
|
||||
historyOcrWorker.join();
|
||||
if (historySearchWorker.joinable())
|
||||
historySearchWorker.join();
|
||||
if (worker.joinable())
|
||||
worker.join();
|
||||
RemoveClipboardFormatListener(window);
|
||||
@@ -290,6 +299,83 @@ void App::enqueue(std::function<void()> job)
|
||||
}
|
||||
jobsReady.notify_one();
|
||||
}
|
||||
void App::enqueueHistorySearch(std::function<void()> job)
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(jobsMutex);
|
||||
if (stop) return;
|
||||
// Only the latest query matters. Clipboard capture and OCR must not delay it.
|
||||
pendingHistorySearch = std::move(job);
|
||||
if (!historySearchWorker.joinable())
|
||||
historySearchWorker = std::thread([this]
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
std::function<void()> searchJob;
|
||||
{
|
||||
std::unique_lock lock(jobsMutex);
|
||||
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
|
||||
if (stop) return;
|
||||
searchJob = std::move(pendingHistorySearch);
|
||||
pendingHistorySearch = {};
|
||||
}
|
||||
try { searchJob(); }
|
||||
catch (const std::exception &e) { report(wide(e.what())); }
|
||||
}
|
||||
});
|
||||
}
|
||||
historySearchReady.notify_one();
|
||||
}
|
||||
void App::scheduleHistoryOcr(const Settings &config)
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(jobsMutex);
|
||||
if (stop || !history || !config.history) return;
|
||||
historyOcrSettings = config;
|
||||
historyOcrRequested = true;
|
||||
if (!historyOcrWorker.joinable())
|
||||
historyOcrWorker = std::thread([this]
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
Settings config;
|
||||
{
|
||||
std::unique_lock lock(jobsMutex);
|
||||
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
|
||||
if (stop) return;
|
||||
historyOcrRequested = false;
|
||||
config = historyOcrSettings;
|
||||
}
|
||||
for (const auto &entry : history->entries())
|
||||
{
|
||||
{ std::lock_guard lock(jobsMutex); if (stop) return; }
|
||||
auto profile = ocrProfile(config);
|
||||
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
|
||||
try
|
||||
{
|
||||
auto text = recognizeText(history->load(entry.id), config);
|
||||
std::lock_guard lock(jobsMutex);
|
||||
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
|
||||
if (history->saveOcr(entry.id, text, config))
|
||||
PostMessageW(window, HistoryMessage, 0, 0);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
// Automatic OCR never changes the clipboard or opens error dialogs.
|
||||
log(L"History OCR: " + wide(e.what()));
|
||||
try {
|
||||
std::lock_guard lock(jobsMutex);
|
||||
if (!stop && ocrProfile(historyOcrSettings) == profile)
|
||||
history->saveOcr(entry.id, std::nullopt, config);
|
||||
}
|
||||
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
historyOcrReady.notify_one();
|
||||
}
|
||||
void App::report(const std::wstring &text)
|
||||
{
|
||||
log(text);
|
||||
@@ -332,6 +418,7 @@ void App::applySettings(const Settings &s)
|
||||
{
|
||||
bool autostartChanged = false;
|
||||
validateSettings(s);
|
||||
validateTesseract(s);
|
||||
if (s.autoCorrect)
|
||||
{
|
||||
AutoCorrector probe;
|
||||
@@ -374,6 +461,7 @@ void App::applySettings(const Settings &s)
|
||||
}
|
||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||
scheduleHistoryOcr(s);
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||
}
|
||||
@@ -587,6 +675,7 @@ void App::dispatch(int command)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (command == 23) { checkUpdate(true); return; }
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
@@ -1050,6 +1139,7 @@ void App::captureHistory()
|
||||
!f.data[1] && !f.data[2] && !f.data[3])
|
||||
return;
|
||||
history->add(clip, app, config);
|
||||
scheduleHistoryOcr(config);
|
||||
PostMessageW(window, HistoryMessage, 0, 0);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -1077,7 +1167,9 @@ void App::recognizeEntry(std::wstring id)
|
||||
} reset{busy};
|
||||
try
|
||||
{
|
||||
auto text = recognizeText(history->load(id));
|
||||
auto cached = history->ocrText(id, ocrProfile(config));
|
||||
auto text = cached ? *cached : recognizeText(history->load(id), config);
|
||||
if (!cached) history->saveOcr(id, text, config);
|
||||
if (text.empty())
|
||||
{
|
||||
report(L"No text was found in the image.");
|
||||
@@ -1238,6 +1330,8 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (app->updateWorker.joinable()) app->updateWorker.join();
|
||||
app->updateRunning = false;
|
||||
app->installingUpdate = false;
|
||||
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
|
||||
(result->error.empty() ? L"" : L"; " + result->error));
|
||||
if (result->state == L"prepared")
|
||||
{
|
||||
// The helper has verified and staged the package; it waits for process exit.
|
||||
@@ -1253,14 +1347,18 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
||||
app->checkUpdate(true, true, result->version);
|
||||
}
|
||||
else
|
||||
else if (app->notifiedUpdate != result->version)
|
||||
{
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
|
||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
|
||||
auto text = result->version + L" is available. Select Update in the tray menu to install.";
|
||||
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
||||
if (app->updateNotifier(result->version))
|
||||
{
|
||||
app->notifiedUpdate = result->version;
|
||||
log(L"Update notification submitted to Windows: " + result->version);
|
||||
}
|
||||
else
|
||||
{
|
||||
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
|
||||
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (result->state == L"current")
|
||||
@@ -1273,7 +1371,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
else if (result->interactive)
|
||||
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
||||
else
|
||||
diagnosticEvent(L"Startup update check failed: " + result->error);
|
||||
diagnosticEvent(L"Background update check failed: " + result->error);
|
||||
return 0;
|
||||
}
|
||||
case WM_HOTKEY:
|
||||
@@ -1337,7 +1435,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_TIMER:
|
||||
if (wp == 5)
|
||||
{
|
||||
KillTimer(hwnd, 5);
|
||||
// After the initial 5-second check, repeat while the resident stays open.
|
||||
// Rearm even when a check is already running or the previous one failed.
|
||||
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
|
||||
app->checkUpdate(false);
|
||||
return 0;
|
||||
}
|
||||
@@ -1438,6 +1538,8 @@ int App::execute()
|
||||
if (!keyboardHook || !mouseHook)
|
||||
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
||||
AddClipboardFormatListener(window);
|
||||
// Resume pending OCR, including older images without a saved OCR state.
|
||||
scheduleHistoryOcr(settings);
|
||||
addTray();
|
||||
SetTimer(window, 1, 60000, nullptr);
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
@@ -1452,6 +1554,9 @@ int App::execute()
|
||||
confirmUpdatedStartup(updateStartupEvent);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
try { registerScreenshotNotifications(); }
|
||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||
auto screenshotActivation=startScreenshotActivationListener();
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||
{
|
||||
@@ -1470,6 +1575,7 @@ int App::execute()
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
|
||||
return (int)msg.wParam;
|
||||
}
|
||||
} // namespace ks
|
||||
|
||||
@@ -61,6 +61,7 @@ class App
|
||||
void recognizeEntry(std::wstring);
|
||||
void captureHistory();
|
||||
void enqueue(std::function<void()>);
|
||||
void enqueueHistorySearch(std::function<void()>);
|
||||
void report(const std::wstring &);
|
||||
void trayMenu();
|
||||
void addTray();
|
||||
@@ -83,9 +84,18 @@ class App
|
||||
std::condition_variable jobsReady;
|
||||
std::deque<std::function<void()>> jobs;
|
||||
std::thread worker;
|
||||
std::thread historySearchWorker;
|
||||
std::condition_variable historySearchReady;
|
||||
std::function<void()> pendingHistorySearch;
|
||||
std::thread historyOcrWorker;
|
||||
std::condition_variable historyOcrReady;
|
||||
bool historyOcrRequested{};
|
||||
Settings historyOcrSettings;
|
||||
void scheduleHistoryOcr(const Settings &);
|
||||
std::thread updateWorker;
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
std::wstring availableUpdate;
|
||||
std::wstring availableUpdate, notifiedUpdate;
|
||||
bool (*updateNotifier)(const std::wstring &){};
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
@@ -99,7 +109,7 @@ class App
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||
};
|
||||
void showHistory(App &);
|
||||
void showHistory(App &, bool visible = true);
|
||||
void launchScreenshot(App &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
|
||||
+5
-1
@@ -48,6 +48,10 @@ struct Settings
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
bool writeLog = true;
|
||||
bool screenshotAutoOpenEditor = false;
|
||||
bool useTesseract = false;
|
||||
std::wstring tesseractPath;
|
||||
std::wstring tesseractLanguages = L"ukr+eng+rus";
|
||||
int autoMaxRank = 0;
|
||||
int autoMinLength = 3;
|
||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||
@@ -59,7 +63,7 @@ struct Settings
|
||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V",
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
||||
screenshot = L"Win+Shift+S";
|
||||
std::vector<std::wstring> cycle, direct, directIds;
|
||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
|
||||
@@ -45,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
cmd = 21;
|
||||
else if (arg == L"--exit")
|
||||
cmd = 99;
|
||||
else if (arg == L"--check-update")
|
||||
cmd = 23;
|
||||
LocalFree(argv);
|
||||
if (existing)
|
||||
{
|
||||
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
if (window)
|
||||
{
|
||||
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
|
||||
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
||||
DWORD_PTR result{};
|
||||
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
||||
|
||||
+115
@@ -271,4 +271,119 @@ std::wstring recognizeText(const Clip &clip)
|
||||
WindowsDeleteString(text);
|
||||
return out;
|
||||
}
|
||||
std::filesystem::path tesseractExecutable(const Settings &s)
|
||||
{
|
||||
if (!s.tesseractPath.empty())
|
||||
{
|
||||
std::filesystem::path path(s.tesseractPath);
|
||||
if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe");
|
||||
return path;
|
||||
}
|
||||
for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"})
|
||||
{
|
||||
wchar_t base[32768]{};
|
||||
auto length = GetEnvironmentVariableW(variable, base, std::size(base));
|
||||
if (!length || length >= std::size(base)) continue;
|
||||
auto root = std::filesystem::path(base);
|
||||
for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"})
|
||||
if (std::filesystem::is_regular_file(root / relative)) return root / relative;
|
||||
}
|
||||
throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options.");
|
||||
}
|
||||
std::wstring ocrProfile(const Settings &s)
|
||||
{
|
||||
return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows";
|
||||
}
|
||||
namespace
|
||||
{
|
||||
struct TessLibrary
|
||||
{
|
||||
HMODULE module{};
|
||||
explicit TessLibrary(const Settings &s)
|
||||
{
|
||||
auto exe = tesseractExecutable(s);
|
||||
if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist");
|
||||
auto library = exe.parent_path() / L"libtesseract-5.dll";
|
||||
module = LoadLibraryExW(library.c_str(), nullptr,
|
||||
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||
if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies.");
|
||||
}
|
||||
~TessLibrary() { if (module) FreeLibrary(module); }
|
||||
template<class T> T symbol(const char *name)
|
||||
{
|
||||
auto address = GetProcAddress(module, name);
|
||||
if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name);
|
||||
T result; static_assert(sizeof(result) == sizeof(address));
|
||||
memcpy(&result, &address, sizeof(result));
|
||||
return result;
|
||||
}
|
||||
};
|
||||
struct TessEngine
|
||||
{
|
||||
TessLibrary library;
|
||||
void *handle{};
|
||||
void (*destroy)(void *){};
|
||||
explicit TessEngine(const Settings &s) : library(s)
|
||||
{
|
||||
auto folder = tesseractExecutable(s).parent_path() / L"tessdata";
|
||||
size_t start = 0;
|
||||
do
|
||||
{
|
||||
auto end = s.tesseractLanguages.find(L'+', start);
|
||||
auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start);
|
||||
if (!std::filesystem::is_regular_file(folder / (language + L".traineddata")))
|
||||
throw std::runtime_error("Tesseract language is not installed: " + utf8(language));
|
||||
if (end == std::wstring::npos) break;
|
||||
start = end + 1;
|
||||
} while (true);
|
||||
auto create = library.symbol<void *(*)()>("TessBaseAPICreate");
|
||||
destroy = library.symbol<void (*)(void *)>("TessBaseAPIDelete");
|
||||
auto init = library.symbol<int (*)(void *, const char *, const char *)>("TessBaseAPIInit3");
|
||||
handle = create();
|
||||
if (!handle) throw std::runtime_error("Cannot create Tesseract engine");
|
||||
if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0)
|
||||
{
|
||||
destroy(handle); handle = nullptr;
|
||||
throw std::runtime_error("Cannot initialize the selected Tesseract languages");
|
||||
}
|
||||
}
|
||||
~TessEngine() { if (handle) destroy(handle); }
|
||||
};
|
||||
}
|
||||
void validateTesseract(const Settings &s)
|
||||
{
|
||||
if (s.useTesseract) { TessEngine engine(s); }
|
||||
}
|
||||
std::wstring recognizeText(const Clip &clip, const Settings &s)
|
||||
{
|
||||
if (!s.useTesseract) return recognizeText(clip);
|
||||
auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult);
|
||||
struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)};
|
||||
// Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs.
|
||||
TessEngine engine(s);
|
||||
auto &lib = engine.library;
|
||||
auto image = lib.symbol<void (*)(void *, const unsigned char *, int, int, int, int)>("TessBaseAPISetImage");
|
||||
auto mode = lib.symbol<void (*)(void *, int)>("TessBaseAPISetPageSegMode");
|
||||
auto recognize = lib.symbol<int (*)(void *, void *)>("TessBaseAPIRecognize");
|
||||
auto getText = lib.symbol<char *(*)(void *)>("TessBaseAPIGetUTF8Text");
|
||||
auto freeText = lib.symbol<void (*)(const char *)>("TessDeleteText");
|
||||
auto createMonitor = lib.symbol<void *(*)()>("TessMonitorCreate");
|
||||
auto freeMonitor = lib.symbol<void (*)(void *)>("TessMonitorDelete");
|
||||
auto deadline = lib.symbol<void (*)(void *, int)>("TessMonitorSetDeadlineMSecs");
|
||||
std::unique_ptr<void, void (*)(void *)> monitor(createMonitor(), freeMonitor);
|
||||
if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor");
|
||||
auto pixels = decodeImage(clip);
|
||||
Bytes gray(size_t(pixels.width) * pixels.height);
|
||||
for (size_t i = 0; i < gray.size(); ++i)
|
||||
gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8);
|
||||
image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width);
|
||||
mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs.
|
||||
deadline(monitor.get(), 30000);
|
||||
if (recognize(engine.handle, monitor.get()) != 0)
|
||||
throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds");
|
||||
std::unique_ptr<char, void (*)(const char *)> text(getText(engine.handle), freeText);
|
||||
if (!text) throw std::runtime_error("Tesseract returned no result");
|
||||
return wide(text.get());
|
||||
}
|
||||
} // namespace ks
|
||||
|
||||
@@ -10,4 +10,8 @@ struct Pixels
|
||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||
Format imageDib(const Pixels &);
|
||||
std::wstring recognizeText(const Clip &);
|
||||
std::wstring recognizeText(const Clip &, const Settings &);
|
||||
std::filesystem::path tesseractExecutable(const Settings &);
|
||||
void validateTesseract(const Settings &);
|
||||
std::wstring ocrProfile(const Settings &);
|
||||
} // namespace ks
|
||||
|
||||
+284
-64
@@ -1,7 +1,10 @@
|
||||
#include "screenshot.h"
|
||||
#include "ocr.h"
|
||||
#include "../resources/resource.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <unordered_set>
|
||||
#include <windowsx.h>
|
||||
#include <commctrl.h>
|
||||
#include <commdlg.h>
|
||||
@@ -50,7 +53,7 @@ bool confirmScreenshotClose(HWND owner)
|
||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||
return SUCCEEDED(result) && button==IDYES;
|
||||
}
|
||||
constexpr int ToolIds[]{10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||
struct Surface
|
||||
{
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
@@ -120,14 +123,53 @@ bool chooseTextFont(HWND owner,Mark &m)
|
||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||
return true;
|
||||
}
|
||||
constexpr int CursorTools[]{10,11,22,23,24,25,19};
|
||||
HCURSOR makeToolCursor(int tool,int size)
|
||||
{
|
||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
||||
auto bitmap=CreateBitmap(size,size*2,1,1,nullptr);
|
||||
auto dc=CreateCompatibleDC(nullptr);
|
||||
if(!bitmap || !dc) { if(bitmap) DeleteObject(bitmap); if(dc) DeleteDC(dc); return nullptr; }
|
||||
auto oldBitmap=SelectObject(dc,bitmap);
|
||||
PatBlt(dc,0,0,size,size,WHITENESS); PatBlt(dc,0,size,size,size,BLACKNESS);
|
||||
auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
|
||||
auto draw=[&](int offset,int width,COLORREF color) {
|
||||
auto pen=CreatePen(PS_SOLID,width,color); auto oldPen=SelectObject(dc,pen);
|
||||
auto x=[&](int v){return MulDiv(v,size,32);};
|
||||
auto y=[&](int v){return offset+MulDiv(v,size,32);};
|
||||
auto line=[&](int a,int b,int c,int d){MoveToEx(dc,x(a),y(b),nullptr); LineTo(dc,x(c),y(d));};
|
||||
line(1,7,13,7); line(7,1,7,13); // The cross center is the exact drawing point.
|
||||
switch(tool) {
|
||||
case 10:
|
||||
Ellipse(dc,x(17),y(17),x(30),y(30));
|
||||
line(22,21,24,20); line(24,20,24,27); line(21,27,27,27); break;
|
||||
case 11: line(18,29,29,18); line(22,18,29,18); line(29,18,29,25); break;
|
||||
case 22: line(18,29,29,18); 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 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
||||
case 19:
|
||||
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
|
||||
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
|
||||
}
|
||||
SelectObject(dc,oldPen); DeleteObject(pen);
|
||||
};
|
||||
int outline=std::max(3,MulDiv(4,size,32)),stroke=std::max(1,MulDiv(2,size,32));
|
||||
draw(0,outline,RGB(0,0,0));
|
||||
draw(size,outline,RGB(255,255,255)); draw(size,stroke,RGB(0,0,0));
|
||||
SelectObject(dc,oldBrush); SelectObject(dc,oldBitmap); DeleteDC(dc);
|
||||
ICONINFO info{}; info.fIcon=FALSE; info.xHotspot=info.yHotspot=MulDiv(7,size,32); info.hbmMask=bitmap;
|
||||
auto cursor=(HCURSOR)CreateIconIndirect(&info); DeleteObject(bitmap); return cursor;
|
||||
}
|
||||
struct Capture
|
||||
{
|
||||
ScreenshotHost &app;
|
||||
std::unique_ptr<Surface> image;
|
||||
std::unique_ptr<Surface> dimmed, frame;
|
||||
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
|
||||
bool autoOpenEditor;
|
||||
bool highlight{};
|
||||
bool freehand=true;
|
||||
bool pointer=false, freehand=true;
|
||||
bool straight{}, textTool{};
|
||||
Mark textDefaults{};
|
||||
ScreenshotShape shape=ScreenshotShape::None;
|
||||
@@ -155,14 +197,16 @@ struct Capture
|
||||
HICON toolIcons[3][15]{};
|
||||
int iconSize{};
|
||||
HCURSOR pencilCursor{};
|
||||
HCURSOR drawingCursors[std::size(CursorTools)]{};
|
||||
int pencilCursorSize{};
|
||||
int unit = 1, panelHeight = 1, statusHeight = 0;
|
||||
int hoveredTool=0;
|
||||
MarkPart hoveredPart=MarkPart::None;
|
||||
std::wstring statusText;
|
||||
explicit Capture(ScreenshotHost &a) : app(a) {}
|
||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
||||
~Capture() {
|
||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
|
||||
if (font) DeleteObject(font);
|
||||
if(inlineFont) DeleteObject(inlineFont);
|
||||
for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon);
|
||||
@@ -195,7 +239,7 @@ void updateStatus(Capture &c)
|
||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
||||
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
||||
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Arrow endpoints can snap to numbered markers";
|
||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows";
|
||||
else if(c.selected>=0) {
|
||||
const auto &m=c.marks[c.selected];
|
||||
if(c.selectedLabel || part==MarkPart::Label)
|
||||
@@ -203,11 +247,12 @@ void updateStatus(Capture &c)
|
||||
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
|
||||
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
|
||||
}
|
||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = draw over an object";
|
||||
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object";
|
||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag = draw over an object | F2 on a line = caption";
|
||||
else text=L"Number (N): click to add | Drag objects to move | Ctrl+C = copy screenshot";
|
||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
|
||||
else if(c.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";
|
||||
if(text!=c.statusText) {
|
||||
c.statusText=std::move(text);
|
||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||
@@ -248,6 +293,15 @@ void positionInlineText(Capture &c)
|
||||
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);
|
||||
}
|
||||
Mark textStyleDefaults(const Mark &source)
|
||||
{
|
||||
// Reuse typography only, never an object's angle, position, identity or links.
|
||||
Mark style{};
|
||||
style.fontName=source.fontName; style.fontSize=source.fontSize;
|
||||
style.fontWeight=source.fontWeight; style.fontItalic=source.fontItalic;
|
||||
style.color=source.color;
|
||||
return style;
|
||||
}
|
||||
void finishInlineText(HWND w,Capture &c,bool commit)
|
||||
{
|
||||
if(!c.inlineText) return;
|
||||
@@ -270,8 +324,9 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
||||
} else if(!empty) {
|
||||
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
|
||||
}
|
||||
if(!empty) c.textDefaults=c.textDraft;
|
||||
if(!empty) c.textDefaults=textStyleDefaults(c.textDraft);
|
||||
}
|
||||
if(commit) resolveScreenshotLinks(c.marks);
|
||||
c.textIndex=-1; c.editingLabel=false;
|
||||
InvalidateRect(w,nullptr,FALSE);
|
||||
}
|
||||
@@ -508,6 +563,11 @@ void layout(HWND w, Capture &c)
|
||||
int dpi = GetDpiForWindow(w);
|
||||
int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi);
|
||||
if(cursorSize!=c.pencilCursorSize) {
|
||||
for(size_t i=0;i<std::size(CursorTools);++i) {
|
||||
auto cursor=makeToolCursor(CursorTools[i],cursorSize);
|
||||
if(c.drawingCursors[i]) DestroyCursor(c.drawingCursors[i]);
|
||||
c.drawingCursors[i]=cursor;
|
||||
}
|
||||
auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0);
|
||||
if(cursor) {
|
||||
if(c.pencilCursor) DestroyCursor(c.pencilCursor);
|
||||
@@ -534,7 +594,7 @@ void layout(HWND w, Capture &c)
|
||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||
r.bottom-=c.statusHeight;
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,380,416,464};
|
||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
|
||||
static_assert(std::size(positions)==std::size(ToolIds));
|
||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||
{
|
||||
@@ -573,6 +633,15 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
||||
{
|
||||
if((c.arrow || c.highlight || c.freehand || c.textTool) && (GetKeyState(VK_CONTROL)&0x8000)) return {-1,MarkPart::None};
|
||||
double tolerance=double(std::max(6,c.unit/6))*c.image->width/std::max(1L,c.view.right-c.view.left);
|
||||
// Badges are painted above connectors. Their visible bodies must also win
|
||||
// hit testing, even when an attached connector is currently selected.
|
||||
for(int i=int(c.marks.size())-1;i>=0;--i) {
|
||||
const auto &m=c.marks[i];
|
||||
if(m.number && std::hypot(double(p.x-m.from.x),double(p.y-m.from.y))<=m.radius) {
|
||||
auto part=hitScreenshotMark(m,p,tolerance);
|
||||
return {i,part};
|
||||
}
|
||||
}
|
||||
if(c.selected>=0)
|
||||
{
|
||||
const auto &m=c.marks[c.selected];
|
||||
@@ -594,30 +663,41 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
||||
}
|
||||
return {-1,MarkPart::None};
|
||||
}
|
||||
HCURSOR toolCursor(const Capture &c)
|
||||
{
|
||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
||||
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||
return LoadCursorW(nullptr,IDC_CROSS);
|
||||
}
|
||||
HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
||||
{
|
||||
if(!c.editing) return LoadCursorW(nullptr,IDC_CROSS);
|
||||
LPCWSTR cursor=nullptr;
|
||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
||||
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
||||
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
||||
const auto &mark=c.marks[hit.first];
|
||||
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
|
||||
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
|
||||
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
|
||||
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
|
||||
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
|
||||
cursor=cursors[axis];
|
||||
}
|
||||
return cursor?LoadCursorW(nullptr,cursor):inside?toolCursor(c):LoadCursorW(nullptr,IDC_ARROW);
|
||||
}
|
||||
void captureCursor(HWND w, Capture &c)
|
||||
{
|
||||
LPCWSTR cursor=IDC_CROSS;
|
||||
if(c.editing)
|
||||
{
|
||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||
auto hit=c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false));
|
||||
if(!c.dragging && !PtInRect(&c.view,p) && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||
if(!c.dragging && !PtInRect(&c.view,p)) cursor=IDC_ARROW;
|
||||
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::Radius) cursor=IDC_SIZEWE;
|
||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness)
|
||||
{
|
||||
const auto &mark=c.marks[hit.first];
|
||||
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
|
||||
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
|
||||
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
|
||||
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
|
||||
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
|
||||
cursor=cursors[axis];
|
||||
}
|
||||
}
|
||||
SetCursor(c.editing && c.freehand && cursor==IDC_CROSS && c.pencilCursor?c.pencilCursor:LoadCursorW(nullptr,cursor));
|
||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||
bool inside=PtInRect(&c.view,p)!=FALSE;
|
||||
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
|
||||
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||
SetCursor(actionCursor(c,hit,inside || c.dragging));
|
||||
if(c.editing) {
|
||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||
c.hoveredPart=markAt(c,imagePoint(c,p,false)).second;
|
||||
@@ -684,7 +764,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
{
|
||||
auto dc=item.hDC;
|
||||
RECT r=item.rcItem;
|
||||
bool active=(item.CtlID==10 && !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==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
||||
bool disabled=item.itemState&ODS_DISABLED;
|
||||
@@ -700,7 +780,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||
auto icon=c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
||||
if(item.CtlID==20) {
|
||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
||||
@@ -761,7 +841,7 @@ void edit(HWND w, Capture &c)
|
||||
InitCommonControlsEx(&statusControls);
|
||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||
const wchar_t *labels[]{L"Number (N)", L"Arrow (A)", L"Straight line (L)", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||
static_assert(std::size(labels)==std::size(ToolIds));
|
||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||
@@ -801,6 +881,27 @@ void edit(HWND w, Capture &c)
|
||||
std::min(LONG(MulDiv(700,GetDpiForWindow(w),96)),work.bottom-work.top-40),SWP_FRAMECHANGED|SWP_SHOWWINDOW);
|
||||
layout(w,c); SetForegroundWindow(w); SetFocus(w);
|
||||
}
|
||||
void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=writeClipboard)
|
||||
{
|
||||
if(!c.app.notify) { edit(w,c); return; }
|
||||
auto r=region(c.start,c.end);
|
||||
if(r.right-r.left<2 || r.bottom-r.top<2) return;
|
||||
Surface cropped(r.right-r.left,r.bottom-r.top);
|
||||
BitBlt(cropped.dc,0,0,cropped.width,cropped.height,c.image->dc,r.left,r.top,SRCCOPY);
|
||||
auto clip=screenshotClip(cropped.dc,cropped.width,cropped.height,{});
|
||||
publish(c.app.window,clip);
|
||||
c.copied=true;
|
||||
// A successful toast submission does not guarantee that Windows shows a banner.
|
||||
if(c.autoOpenEditor) edit(w,c);
|
||||
// Leave fullscreen capture before submitting the toast: Windows may suppress
|
||||
// banners while a fullscreen application is visible.
|
||||
else ShowWindow(w,SW_HIDE);
|
||||
bool notified=c.app.notify(clip);
|
||||
if(!c.autoOpenEditor) {
|
||||
if(notified) DestroyWindow(w);
|
||||
else edit(w,c);
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto c = reinterpret_cast<Capture *>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
||||
@@ -832,7 +933,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
break;
|
||||
case WM_DRAWITEM:
|
||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=26) || 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) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||
break;
|
||||
case WM_CTLCOLORSTATIC:
|
||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||
@@ -859,7 +960,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
auto draft=c->marks[c->selected];
|
||||
if(chooseTextFont(w,draft)) {
|
||||
c->remember(c->marks); c->marks[c->selected]=draft;
|
||||
c->textDefaults=draft.isLine()?screenshotArrowLabel(draft):draft;
|
||||
c->textDefaults=textStyleDefaults(draft);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
}
|
||||
} else chooseTextFont(w,c->textDefaults);
|
||||
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
@@ -913,7 +1015,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
auto &mark=c->marks[c->selected];
|
||||
int &value=mark.isText()?mark.fontSize:mark.thickness;
|
||||
int next=std::clamp(value+(LOWORD(wp)==16?1:-1),mark.isText()?6:mark.isHighlight()?4:1,mark.isText()?200:mark.isHighlight()?100:30);
|
||||
if(next!=value) { c->remember(c->marks); value=next; }
|
||||
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
||||
}
|
||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||
@@ -922,8 +1024,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||
c->syncHistory();
|
||||
}
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26))
|
||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||
{
|
||||
c->pointer=LOWORD(wp)==27;
|
||||
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||
c->straight=LOWORD(wp)==22;
|
||||
c->textTool=LOWORD(wp)==26;
|
||||
@@ -937,10 +1040,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||
for(int id=22;id<=26;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||
}
|
||||
|
||||
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
case WM_KEYDOWN:
|
||||
if (wp==VK_ESCAPE) {
|
||||
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||
@@ -973,6 +1076,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
InvalidateRect(w,nullptr,FALSE);
|
||||
}
|
||||
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
|
||||
if(wp=='V') SendMessageW(w,WM_COMMAND,27,0);
|
||||
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
|
||||
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
|
||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||
@@ -1032,6 +1136,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
InvalidateRect(w,nullptr,FALSE); return 0;
|
||||
}
|
||||
c->selected=-1; c->selectedLabel=false;
|
||||
if(c->pointer) { InvalidateRect(w,nullptr,FALSE); updateStatus(*c); return 0; }
|
||||
}
|
||||
if(c->editing && c->textTool) {
|
||||
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
|
||||
@@ -1100,10 +1205,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (msg==WM_LBUTTONUP)
|
||||
{
|
||||
c->dragging=false; ReleaseCapture();
|
||||
if (!c->editing) edit(w,*c);
|
||||
if (!c->editing) { finishSelection(w,*c); return 0; }
|
||||
else if(c->part!=MarkPart::None)
|
||||
{
|
||||
if(c->part==MarkPart::Start || c->part==MarkPart::End)
|
||||
if((c->part==MarkPart::Start || c->part==MarkPart::End) &&
|
||||
(c->start.x!=c->end.x || c->start.y!=c->end.y))
|
||||
{
|
||||
auto &mark=c->marks[c->selected];
|
||||
linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6);
|
||||
@@ -1330,27 +1436,107 @@ ScreenshotMark cloneScreenshotMark(const ScreenshotMark &original,const std::vec
|
||||
{
|
||||
auto result=original;
|
||||
result.fromNumber=result.toNumber=0;
|
||||
result.id=0; result.fromLink={}; result.toLink={};
|
||||
if(result.number) result.number=nextScreenshotNumber(marks);
|
||||
auto bounds=markBounds(result);
|
||||
LONG dx=bounds.right+16<width?16:-16;
|
||||
LONG dy=bounds.bottom+16<height?16:-16;
|
||||
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
|
||||
}
|
||||
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
||||
const std::vector<ScreenshotMark> &marks, double tolerance)
|
||||
namespace {
|
||||
void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
||||
{
|
||||
auto &link=start?arrow.fromNumber:arrow.toNumber;
|
||||
link=0;
|
||||
if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return;
|
||||
for(auto it=marks.rbegin();it!=marks.rend();++it)
|
||||
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance)
|
||||
{
|
||||
link=it->number;
|
||||
break;
|
||||
static unsigned long long next=1;
|
||||
for(const auto &m:marks) next=std::max(next,m.id+1);
|
||||
std::unordered_set<unsigned long long> used;
|
||||
for(auto &m:marks) {
|
||||
if(!m.id || !used.insert(m.id).second) { m.id=next++; used.insert(m.id); }
|
||||
}
|
||||
}
|
||||
POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link)
|
||||
{
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))};
|
||||
return m.isText()?rotateTextPoint(m,p):p;
|
||||
}
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
double along=std::clamp(link.u,0.0,1.0)*segments;
|
||||
size_t i=std::min(size_t(along),segments-1); double t=along-i;
|
||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||
return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))};
|
||||
}
|
||||
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
||||
{
|
||||
link={m.id,0,0};
|
||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop;
|
||||
double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top);
|
||||
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
||||
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
||||
if(m.shape==ScreenshotShape::Circle || m.shape==ScreenshotShape::Ellipse) {
|
||||
double dx=2*x-1,dy=2*y-1,length=std::hypot(dx,dy);
|
||||
inside=length<=1;
|
||||
if(length<0.001) { dx=1; dy=0; length=1; }
|
||||
link.u=(1+dx/length)/2; link.v=(1+dy/length)/2;
|
||||
} else {
|
||||
link.u=std::clamp(x,0.0,1.0); link.v=std::clamp(y,0.0,1.0);
|
||||
if(inside) {
|
||||
double edges[]{x*w,(1-x)*w,y*h,(1-y)*h};
|
||||
auto side=std::min_element(edges,edges+4)-edges;
|
||||
if(side<2) link.u=side==0?0:1; else link.v=side==2?0:1;
|
||||
}
|
||||
}
|
||||
auto anchor=objectAnchor(m,link);
|
||||
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
||||
}
|
||||
double best=1e100;
|
||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||
for(size_t i=0;i<segments;++i) {
|
||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||
double dx=b.x-a.x,dy=b.y-a.y,length=dx*dx+dy*dy;
|
||||
double t=length?std::clamp(((drop.x-a.x)*dx+(drop.y-a.y)*dy)/length,0.0,1.0):0;
|
||||
double distance=std::hypot(drop.x-a.x-t*dx,drop.y-a.y-t*dy);
|
||||
if(distance<best) { best=distance; link.u=(i+t)/segments; }
|
||||
}
|
||||
return best<=tolerance+m.thickness/2.0;
|
||||
}
|
||||
}
|
||||
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
||||
std::vector<ScreenshotMark> &marks, double tolerance)
|
||||
{
|
||||
assignMarkIds(marks);
|
||||
auto &number=start?arrow.fromNumber:arrow.toNumber;
|
||||
auto &link=start?arrow.fromLink:arrow.toLink;
|
||||
number=0; link={};
|
||||
if(!arrow.isLine()) return;
|
||||
// Number badges are drawn above other objects.
|
||||
for(auto it=marks.rbegin();it!=marks.rend();++it)
|
||||
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) {
|
||||
number=it->number; return;
|
||||
}
|
||||
auto wouldCycle=[&](unsigned long long id) {
|
||||
std::vector<unsigned long long> pending{id}; std::unordered_set<unsigned long long> seen;
|
||||
while(!pending.empty()) {
|
||||
auto current=pending.back(); pending.pop_back();
|
||||
if(arrow.id && current==arrow.id) return true;
|
||||
if(!current || !seen.insert(current).second) continue;
|
||||
for(const auto &m:marks) if(m.id==current) {
|
||||
pending.push_back(m.fromLink.target); pending.push_back(m.toLink.target); break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for(auto it=marks.rbegin();it!=marks.rend();++it) {
|
||||
if(it->number || &*it==&arrow || (arrow.id && it->id==arrow.id)) continue;
|
||||
ScreenshotLink candidate;
|
||||
if(findObjectAnchor(*it,drop,tolerance,candidate) && !wouldCycle(it->id)) { link=candidate; return; }
|
||||
}
|
||||
}
|
||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||
{
|
||||
assignMarkIds(marks);
|
||||
auto badge=[&](int number) -> const ScreenshotMark * {
|
||||
if(number) for(const auto &m:marks) if(m.number==number) return &m;
|
||||
return nullptr;
|
||||
@@ -1360,17 +1546,32 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||
if(length<0.001) { dx=start?1:-1; dy=0; length=1; }
|
||||
return {m.from.x+LONG(std::lround(m.radius*dx/length)),m.from.y+LONG(std::lround(m.radius*dy/length))};
|
||||
};
|
||||
for(auto &m:marks)
|
||||
{
|
||||
if(m.number || m.isHighlight() || m.isText() || m.shape!=ScreenshotShape::None) continue;
|
||||
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
||||
if(!from) m.fromNumber=0;
|
||||
if(!to) m.toNumber=0;
|
||||
POINT start=from?from->from:m.from, end=to?to->from:m.to;
|
||||
if(from) m.from=rim(*from,end,true);
|
||||
if(to) m.to=rim(*to,start,false);
|
||||
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
||||
}
|
||||
std::vector<int> state(marks.size());
|
||||
std::function<void(size_t)> resolve=[&](size_t index) {
|
||||
if(state[index]) return;
|
||||
state[index]=1;
|
||||
auto &m=marks[index];
|
||||
if(m.isLine()) {
|
||||
auto attach=[&](ScreenshotLink &link,POINT &point) {
|
||||
if(!link.target) return;
|
||||
for(size_t i=0;i<marks.size();++i) if(marks[i].id==link.target) {
|
||||
if(state[i]==1) { link={}; return; } // Reject cyclic dependencies.
|
||||
resolve(i); point=objectAnchor(marks[i],link); return;
|
||||
}
|
||||
link={}; // Deleted targets leave endpoints at their last position.
|
||||
};
|
||||
attach(m.fromLink,m.from); attach(m.toLink,m.to);
|
||||
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
||||
if(!from) m.fromNumber=0;
|
||||
if(!to) m.toNumber=0;
|
||||
POINT start=from?from->from:m.from, end=to?to->from:m.to;
|
||||
if(from) m.from=rim(*from,end,true);
|
||||
if(to) m.to=rim(*to,start,false);
|
||||
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
||||
}
|
||||
state[index]=2;
|
||||
};
|
||||
for(size_t i=0;i<marks.size();++i) resolve(i);
|
||||
}
|
||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
{
|
||||
@@ -1438,6 +1639,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
POINT position, int width, int height, bool snapRotation)
|
||||
{
|
||||
auto m=original;
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
@@ -1473,6 +1675,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
else if(part==MarkPart::Body)
|
||||
{
|
||||
m.fromNumber=m.toNumber=0;
|
||||
m.fromLink={}; m.toLink={};
|
||||
auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
|
||||
LONG low=-first,high=LONG(extent-1)-last;
|
||||
if(low>high && m.isText()) {
|
||||
@@ -1492,6 +1695,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||
{
|
||||
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
||||
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
||||
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
||||
@@ -1513,6 +1717,22 @@ Clip screenshotClip(HDC source, int width, int height, const std::vector<Screens
|
||||
memcpy(bytes.data()+sizeof(header),output.pixels,bytes.size()-sizeof(header));
|
||||
return Clip{{{CF_DIB,L"",std::move(bytes)}}};
|
||||
}
|
||||
void showScreenshotEditor(ScreenshotHost &app,const Clip &clip)
|
||||
{
|
||||
auto pixels=decodeImage(clip);
|
||||
auto c=std::make_unique<Capture>(app);
|
||||
c->image=std::make_unique<Surface>(pixels.width,pixels.height);
|
||||
memcpy(c->image->pixels,pixels.bgra.data(),pixels.bgra.size());
|
||||
c->start={0,0}; c->end={(LONG)pixels.width,(LONG)pixels.height}; c->copied=true;
|
||||
WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=captureProc; cls.lpszClassName=L"KSwitcher.Capture";
|
||||
cls.style=CS_DBLCLKS; cls.hCursor=LoadCursorW(nullptr,IDC_CROSS);
|
||||
cls.hIcon=LoadIconW(app.instance,MAKEINTRESOURCEW(IDI_KSWITCHER)); RegisterClassW(&cls);
|
||||
auto window=CreateWindowExW(0,cls.lpszClassName,L"KSwitcher Screenshot",WS_POPUP,0,0,1,1,
|
||||
app.window,nullptr,app.instance,c.get());
|
||||
if(!window) throw std::runtime_error("Cannot open screenshot editor");
|
||||
c->owned=true; ++app.windows;
|
||||
auto state=c.release(); edit(window,*state);
|
||||
}
|
||||
void showScreenshot(ScreenshotHost &app)
|
||||
{
|
||||
if (activeCapture) { showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); return; }
|
||||
|
||||
+10
-1
@@ -7,13 +7,20 @@ struct ScreenshotHost {
|
||||
HINSTANCE instance{};
|
||||
HWND window{};
|
||||
unsigned windows{};
|
||||
bool (*notify)(const Clip &){};
|
||||
bool autoOpenEditor = false;
|
||||
};
|
||||
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
|
||||
void showScreenshot(ScreenshotHost &);
|
||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||
enum class LineEnds { End, Start, Both, None };
|
||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||
struct ScreenshotOffset { double x, y; };
|
||||
struct ScreenshotLink {
|
||||
unsigned long long target{};
|
||||
double u{}, v{}; // Relative boundary position, or fractional position along a path.
|
||||
};
|
||||
struct ScreenshotMark {
|
||||
POINT from, to; int number; int radius = 15; int thickness = 3;
|
||||
int fromNumber = 0, toNumber = 0;
|
||||
@@ -30,6 +37,8 @@ struct ScreenshotMark {
|
||||
std::wstring label;
|
||||
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
||||
bool labelRotationManual=false;
|
||||
unsigned long long id{};
|
||||
ScreenshotLink fromLink, toLink;
|
||||
bool isText() const { return !text.empty(); }
|
||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||
@@ -45,7 +54,7 @@ class ScreenshotHistory {
|
||||
};
|
||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||
const std::vector<ScreenshotMark> &, double tolerance);
|
||||
std::vector<ScreenshotMark> &, double tolerance);
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#include "screenshot_notifications.h"
|
||||
#include <atomic>
|
||||
#include <shlobj.h>
|
||||
#include <propkey.h>
|
||||
#include <propvarutil.h>
|
||||
#include <wrl/client.h>
|
||||
namespace ks {
|
||||
namespace {
|
||||
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||
}
|
||||
namespace {
|
||||
using Microsoft::WRL::ComPtr;
|
||||
void check(HRESULT result) {
|
||||
if(FAILED(result)) throw std::runtime_error("Screenshot registration failed: " + std::to_string((unsigned long)result));
|
||||
}
|
||||
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
|
||||
{
|
||||
HKEY key{};
|
||||
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
|
||||
throw std::runtime_error("Cannot register screenshot notifications");
|
||||
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
|
||||
RegCloseKey(key);
|
||||
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
|
||||
}
|
||||
}
|
||||
void registerScreenshotNotifications()
|
||||
{
|
||||
auto exe=(std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe").wstring();
|
||||
if(!std::filesystem::exists(exe)) throw std::runtime_error("Screenshot helper is missing");
|
||||
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
|
||||
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
|
||||
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
|
||||
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
|
||||
registryString(identity,L"IconUri",exe);
|
||||
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
|
||||
// A shortcut supplies desktop notification identity and its COM activation callback.
|
||||
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
||||
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
|
||||
ComPtr<IPropertyStore> properties; check(link.As(&properties));
|
||||
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
|
||||
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
|
||||
GUID activator=ActivatorId;
|
||||
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
|
||||
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
|
||||
check(properties->Commit());
|
||||
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
|
||||
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
|
||||
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
|
||||
// Notify the shell that notification identity/activation registration changed.
|
||||
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||
}
|
||||
bool validScreenshotToken(const std::wstring &id)
|
||||
{
|
||||
if(id.size()!=36) return false;
|
||||
for(size_t i=0;i<id.size();++i) {
|
||||
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
|
||||
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
|
||||
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
|
||||
struct NotificationInput { LPCWSTR key,value; };
|
||||
struct NotificationCallback : IUnknown {
|
||||
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
|
||||
};
|
||||
class Activator final : public NotificationCallback {
|
||||
std::atomic<ULONG> references{1};
|
||||
DWORD thread=GetCurrentThreadId();
|
||||
std::wstring target;
|
||||
bool quit;
|
||||
public:
|
||||
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||
if(!out) return E_POINTER;
|
||||
*out=nullptr;
|
||||
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
|
||||
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
||||
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
|
||||
const bool update=std::wstring(args)==L"check-update";
|
||||
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
|
||||
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
|
||||
auto command=update ? L"\""+path+L"\" --check-update" :
|
||||
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
|
||||
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
|
||||
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
|
||||
if(ok) {
|
||||
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
|
||||
CloseHandle(process.hThread); CloseHandle(process.hProcess);
|
||||
}
|
||||
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
class ActivatorFactory final : public IClassFactory {
|
||||
std::atomic<ULONG> references{1};
|
||||
std::wstring target;
|
||||
bool quit;
|
||||
public:
|
||||
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||
if(!out) return E_POINTER;
|
||||
*out=nullptr;
|
||||
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
|
||||
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
|
||||
if(outer) return CLASS_E_NOAGGREGATION;
|
||||
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
|
||||
};
|
||||
}
|
||||
int runScreenshotToastActivator()
|
||||
{
|
||||
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
|
||||
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
|
||||
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
|
||||
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
||||
factory->Release();
|
||||
if(SUCCEEDED(result)) {
|
||||
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
|
||||
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
|
||||
if(timer) KillTimer(nullptr,timer);
|
||||
CoRevokeClassObject(registration);
|
||||
}
|
||||
CoUninitialize(); return FAILED(result)?1:0;
|
||||
}
|
||||
DWORD startScreenshotActivationListener()
|
||||
{
|
||||
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
auto factory=new ActivatorFactory(target.wstring(),false);
|
||||
DWORD registration{};
|
||||
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
||||
factory->Release();
|
||||
return SUCCEEDED(result)?registration:0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
#include "app.h"
|
||||
|
||||
namespace ks {
|
||||
void launchScreenshot(App &)
|
||||
void launchScreenshot(App &app)
|
||||
{
|
||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||
auto command=L"\""+path.wstring()+L"\"";
|
||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||
if(app.settings.writeLog) command+=L" --write-log";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
// Grant foreground rights before the child's first selection window opens.
|
||||
|
||||
+54
-4
@@ -1,5 +1,8 @@
|
||||
#include "screenshot.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <commctrl.h>
|
||||
#include <shellapi.h>
|
||||
#include <roapi.h>
|
||||
|
||||
namespace {
|
||||
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
|
||||
@@ -11,6 +14,8 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
|
||||
}
|
||||
if(host && message==ks::ScreenshotRequest) {
|
||||
host->autoOpenEditor=(wp&1)!=0;
|
||||
ks::setLoggingEnabled((wp&2)!=0);
|
||||
try { ks::showScreenshot(*host); }
|
||||
catch(const std::exception &e) {
|
||||
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
|
||||
@@ -18,6 +23,18 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(host && message==WM_COPYDATA) {
|
||||
auto data=(COPYDATASTRUCT *)lp;
|
||||
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
|
||||
std::wstring token((const wchar_t *)data->lpData,36);
|
||||
if(!ks::validScreenshotToken(token)) return FALSE;
|
||||
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
|
||||
catch(const std::exception &e) {
|
||||
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
|
||||
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(host && message==ks::ScreenshotIdle && !host->windows) {
|
||||
DestroyWindow(window);
|
||||
PostQuitMessage(0);
|
||||
@@ -29,6 +46,33 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
||||
{
|
||||
std::wstring token;
|
||||
bool autoOpenEditor=false, writeLog=false;
|
||||
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
|
||||
writeLog=true; --argc;
|
||||
ks::setLoggingEnabled(true);
|
||||
}
|
||||
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
|
||||
LocalFree(argv); return ks::runScreenshotToastActivator();
|
||||
}
|
||||
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
|
||||
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
|
||||
CoUninitialize(); return result;
|
||||
}
|
||||
if(argc==2 && (std::wstring(argv[1])==L"--auto-open-editor=0" ||
|
||||
std::wstring(argv[1])==L"--auto-open-editor=1")) {
|
||||
autoOpenEditor=std::wstring(argv[1])==L"--auto-open-editor=1";
|
||||
} else if(argc>1) {
|
||||
if(argc==3 && std::wstring(argv[1])==L"--open") {
|
||||
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
|
||||
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
|
||||
}
|
||||
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
|
||||
}
|
||||
LocalFree(argv);
|
||||
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
|
||||
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
|
||||
if(!mutex) return 1;
|
||||
// Wait through startup/shutdown races; the controller accepts requests only while alive.
|
||||
@@ -40,30 +84,36 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
||||
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
|
||||
AllowSetForegroundWindow(pid);
|
||||
DWORD_PTR accepted{};
|
||||
if(SendMessageTimeoutW(existing,ks::ScreenshotRequest,0,0,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
|
||||
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,token.empty()?(autoOpenEditor?1:0)|(writeLog?2:0):0,
|
||||
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
|
||||
CloseHandle(mutex); return 0;
|
||||
}
|
||||
}
|
||||
Sleep(50);
|
||||
}
|
||||
if(!acquired) { CloseHandle(mutex); return 1; }
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
RoInitialize(RO_INIT_SINGLETHREADED);
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
|
||||
InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost host{instance};
|
||||
host.notify=ks::notifyScreenshot;
|
||||
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
|
||||
RegisterClassW(&cls);
|
||||
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
|
||||
int result=1;
|
||||
if(host.window) {
|
||||
PostMessageW(host.window,ks::ScreenshotRequest,0,0);
|
||||
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
|
||||
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
|
||||
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
|
||||
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
|
||||
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
|
||||
MSG message{};
|
||||
while((result=GetMessageW(&message,nullptr,0,0))>0) {
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
result=result<0?1:0;
|
||||
}
|
||||
CoUninitialize();
|
||||
RoUninitialize();
|
||||
ReleaseMutex(mutex); CloseHandle(mutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#include "screenshot_notifications.h"
|
||||
#include "ocr.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <aclapi.h>
|
||||
#include <sddl.h>
|
||||
#include <shlobj.h>
|
||||
#include <propkey.h>
|
||||
#include <propvarutil.h>
|
||||
#include <roapi.h>
|
||||
#include <winstring.h>
|
||||
#include <wincodec.h>
|
||||
#ifdef __MINGW32__
|
||||
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
|
||||
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
|
||||
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
|
||||
#endif
|
||||
#include <windows.ui.notifications.h>
|
||||
#include <windows.data.xml.dom.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
namespace ks {
|
||||
namespace {
|
||||
using Microsoft::WRL::ComPtr;
|
||||
namespace Notifications=ABI::Windows::UI::Notifications;
|
||||
namespace Xml=ABI::Windows::Data::Xml::Dom;
|
||||
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||
std::string notificationError;
|
||||
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
|
||||
struct String {
|
||||
HSTRING value{};
|
||||
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
|
||||
~String() { WindowsDeleteString(value); }
|
||||
};
|
||||
std::filesystem::path cacheDirectory()
|
||||
{
|
||||
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
|
||||
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
|
||||
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
|
||||
}
|
||||
std::filesystem::path previewDirectory()
|
||||
{
|
||||
HANDLE token{};
|
||||
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
|
||||
std::vector<BYTE> data(size);
|
||||
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
|
||||
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
|
||||
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
|
||||
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||
std::wstring sid(sidText); LocalFree(sidText);
|
||||
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
|
||||
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
|
||||
// The notification broker cannot load previews from some profile directories.
|
||||
// Keep this cache outside the profile, private to the current user and SYSTEM.
|
||||
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
|
||||
PSECURITY_DESCRIPTOR security{};
|
||||
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
|
||||
check(HRESULT_FROM_WIN32(GetLastError()));
|
||||
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
|
||||
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
|
||||
error=created?ERROR_SUCCESS:GetLastError();
|
||||
LocalFree(security);
|
||||
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
|
||||
// Do not use a pre-created directory or junction controlled by another user.
|
||||
DWORD flags=GetFileAttributesW(path.c_str());
|
||||
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
|
||||
throw std::runtime_error("Invalid screenshot preview directory");
|
||||
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
|
||||
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
|
||||
&owner,nullptr,nullptr,nullptr,&existing);
|
||||
check(HRESULT_FROM_WIN32(error));
|
||||
bool owned=EqualSid(owner,user); LocalFree(existing);
|
||||
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
|
||||
return path;
|
||||
}
|
||||
void cleanCache(const std::filesystem::path &path)
|
||||
{
|
||||
std::vector<std::filesystem::directory_entry> files;
|
||||
for(const auto &file:std::filesystem::directory_iterator(path))
|
||||
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
|
||||
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
|
||||
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
|
||||
uintmax_t size=0;
|
||||
for(size_t i=0;i<files.size();++i) {
|
||||
size+=files[i].file_size();
|
||||
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
|
||||
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
void savePreview(const Clip &clip,const std::filesystem::path &path)
|
||||
{
|
||||
auto pixels=decodeImage(clip,360,220);
|
||||
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
|
||||
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
|
||||
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
|
||||
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
|
||||
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
|
||||
check(frame->SetSize(pixels.width,pixels.height));
|
||||
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
|
||||
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
|
||||
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
|
||||
check(frame->Commit()); check(encoder->Commit());
|
||||
}
|
||||
std::wstring escapeXml(const std::wstring &s)
|
||||
{
|
||||
std::wstring result;
|
||||
for(auto ch:s) { if(ch==L'&') result+=L"&"; else if(ch==L'\"') result+=L"""; else if(ch==L'<') result+=L"<"; else result+=ch; }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||
{
|
||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||
auto directory=cacheDirectory(); cleanCache(directory);
|
||||
auto path=directory/(id+L".capture");
|
||||
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
|
||||
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
|
||||
}
|
||||
bool notifyScreenshot(const Clip &clip)
|
||||
{
|
||||
notificationError.clear();
|
||||
const char *stage="registration";
|
||||
try {
|
||||
registerScreenshotNotifications();
|
||||
check(SetCurrentProcessExplicitAppUserModelID(AppId));
|
||||
stage="notification manager";
|
||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||
stage="notifier";
|
||||
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||
stage="settings";
|
||||
Notifications::NotificationSetting setting{};
|
||||
auto settingResult=notifier->get_Setting(&setting);
|
||||
// A first-time sender has no settings record until its first notification.
|
||||
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
|
||||
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
|
||||
stage="cache";
|
||||
auto directory=cacheDirectory(); cleanCache(directory);
|
||||
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||
std::wstring id(value+1,36);
|
||||
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
|
||||
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
|
||||
atomicWrite(directory/(id+L".capture"),protect(dib->data));
|
||||
stage="preview";
|
||||
auto previews=previewDirectory(); cleanCache(previews);
|
||||
auto preview=previews/(id+L".png"); savePreview(clip,preview);
|
||||
cleanCache(previews);
|
||||
cleanCache(directory);
|
||||
stage="XML";
|
||||
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
|
||||
check(RoActivateInstance(documentClass.value,&documentInstance));
|
||||
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
|
||||
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
|
||||
auto uri=L"file:///"+preview.generic_wstring();
|
||||
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
|
||||
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
|
||||
check(documentIO->LoadXml(content.value));
|
||||
stage="toast factory";
|
||||
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
|
||||
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
|
||||
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
|
||||
String propertyClass(L"Windows.Foundation.PropertyValue");
|
||||
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
|
||||
FILETIME now{}; GetSystemTimeAsFileTime(&now);
|
||||
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
|
||||
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
|
||||
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
|
||||
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
|
||||
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
|
||||
stage="show";
|
||||
check(notifier->Show(toast.Get()));
|
||||
return true;
|
||||
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
|
||||
}
|
||||
std::string screenshotNotificationError() { return notificationError; }
|
||||
|
||||
std::wstring updateToastXml(const std::wstring &version)
|
||||
{
|
||||
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
|
||||
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
|
||||
L" is available. Click to review and install.</text></binding></visual>"
|
||||
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
|
||||
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
|
||||
}
|
||||
bool notifyUpdateAvailable(const std::wstring &version)
|
||||
{
|
||||
try {
|
||||
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
||||
registerScreenshotNotifications();
|
||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||
Notifications::NotificationSetting setting{};
|
||||
auto settingsResult=notifier->get_Setting(&setting);
|
||||
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
|
||||
if(setting!=Notifications::NotificationSetting_Enabled)
|
||||
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
|
||||
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
|
||||
check(RoActivateInstance(documentClass.value,&instance));
|
||||
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
|
||||
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
|
||||
String content(updateToastXml(version)); check(io->LoadXml(content.value));
|
||||
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
|
||||
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
|
||||
// Repeated submissions replace the existing update card in Notification Center.
|
||||
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
|
||||
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
|
||||
check(notifier->Show(toast.Get()));
|
||||
return true;
|
||||
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
namespace ks {
|
||||
bool notifyScreenshot(const Clip &);
|
||||
std::string screenshotNotificationError();
|
||||
void registerScreenshotNotifications();
|
||||
int runScreenshotToastActivator();
|
||||
DWORD startScreenshotActivationListener();
|
||||
bool validScreenshotToken(const std::wstring &);
|
||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||
}
|
||||
+79
-4
@@ -44,6 +44,13 @@ bool validId(const std::wstring &id)
|
||||
} // namespace
|
||||
void validateSettings(const Settings &s)
|
||||
{
|
||||
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
|
||||
throw std::runtime_error("Invalid Tesseract executable path");
|
||||
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
|
||||
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
|
||||
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
|
||||
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
|
||||
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
|
||||
std::vector<Hotkey> used;
|
||||
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
|
||||
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
|
||||
@@ -125,6 +132,9 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
s.switchLayout = j.value("switchSystemLayout", true);
|
||||
s.writeLog = j.value("writeLog", true);
|
||||
s.history = j.value("historyEnabled", true);
|
||||
s.useTesseract = j.value("useTesseract", false);
|
||||
s.tesseractPath = str(j, "tesseractPath");
|
||||
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
|
||||
s.autostart = j.value("startWithSystem", false);
|
||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||
s.awake = j.value("preventScreenLock", false);
|
||||
@@ -151,6 +161,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
s.undo = str(j, "undoHotkey", s.undo);
|
||||
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
||||
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
||||
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
|
||||
if (j.contains("layouts"))
|
||||
{
|
||||
s.cycle.clear();
|
||||
@@ -195,6 +206,9 @@ Bytes encodeSettings(const Settings &s)
|
||||
{"writeLog", s.writeLog},
|
||||
{"autostartAsAdministrator", s.elevatedAutostart},
|
||||
{"historyEnabled", s.history},
|
||||
{"useTesseract", s.useTesseract},
|
||||
{"tesseractPath", utf8(s.tesseractPath)},
|
||||
{"tesseractLanguages", utf8(s.tesseractLanguages)},
|
||||
{"startWithSystem", s.autostart},
|
||||
{"preventScreenLock", s.awake},
|
||||
{"maxItems", s.maxItems},
|
||||
@@ -205,7 +219,8 @@ Bytes encodeSettings(const Settings &s)
|
||||
{"translitHotkey", utf8(s.translit)},
|
||||
{"undoHotkey", utf8(s.undo)},
|
||||
{"clipboardHotkey", utf8(s.clipboard)},
|
||||
{"screenshotHotkey", utf8(s.screenshot)}};
|
||||
{"screenshotHotkey", utf8(s.screenshot)},
|
||||
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
||||
j["layouts"] = Json::array();
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
@@ -295,7 +310,8 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
|
||||
e.value("at", uint64_t{}),
|
||||
e.value("pinned", false),
|
||||
e.value("image", false),
|
||||
e.value("files", false)};
|
||||
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
|
||||
str(e, "ocrProfile", L"windows")};
|
||||
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
|
||||
entries_.push_back(std::move(item));
|
||||
}
|
||||
@@ -311,7 +327,7 @@ void History::saveIndex()
|
||||
{"at", e.at},
|
||||
{"pinned", e.pinned},
|
||||
{"image", e.image},
|
||||
{"files", e.files}});
|
||||
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
|
||||
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
|
||||
}
|
||||
std::vector<Entry> History::entries() const
|
||||
@@ -340,6 +356,7 @@ void History::prune(const Settings &s)
|
||||
total -= entries_[i].bytes;
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
|
||||
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
|
||||
entries_.erase(entries_.begin() + i);
|
||||
}
|
||||
}
|
||||
@@ -427,7 +444,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
||||
GetSystemTimeAsFileTime(&now);
|
||||
entries_.insert(entries_.begin(),
|
||||
Entry{id, text, app, encrypted.size(),
|
||||
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files});
|
||||
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
|
||||
image ? 1 : 0, ocrProfile(s)});
|
||||
prune(s);
|
||||
saveIndex();
|
||||
}
|
||||
@@ -443,6 +461,7 @@ void History::remove(const std::wstring &id)
|
||||
saveIndex();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(folder_ / (id + L".dat"), ec);
|
||||
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
|
||||
}
|
||||
void History::pin(const std::wstring &id)
|
||||
{
|
||||
@@ -462,8 +481,49 @@ void History::clear()
|
||||
{
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
|
||||
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
|
||||
}
|
||||
}
|
||||
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
|
||||
// A late OCR completion must never recreate a deleted/pruned image.
|
||||
auto profile = ocrProfile(settings);
|
||||
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
|
||||
if (text)
|
||||
{
|
||||
auto utf = utf8(*text);
|
||||
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
|
||||
// A version byte also makes empty recognition results encryptable by DPAPI.
|
||||
Bytes payload{1};
|
||||
payload.insert(payload.end(), utf.begin(), utf.end());
|
||||
auto encrypted = protect(payload);
|
||||
auto path = folder_ / (id + L".ocr");
|
||||
std::error_code ec;
|
||||
auto previousBytes = std::filesystem::file_size(path, ec);
|
||||
if (ec) previousBytes = 0;
|
||||
atomicWrite(path, encrypted);
|
||||
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
|
||||
it->ocrState = 2;
|
||||
}
|
||||
else it->ocrState = 3;
|
||||
it->ocrProfile = profile;
|
||||
prune(settings);
|
||||
saveIndex();
|
||||
return true;
|
||||
}
|
||||
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
|
||||
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
|
||||
}
|
||||
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
|
||||
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
|
||||
return wide(std::string(bytes.begin() + 1, bytes.end()));
|
||||
}
|
||||
std::vector<Entry> History::search(const std::wstring &query) const
|
||||
{
|
||||
auto items = entries();
|
||||
@@ -475,6 +535,21 @@ std::vector<Entry> History::search(const std::wstring &query) const
|
||||
{
|
||||
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
|
||||
return false;
|
||||
if (e.ocrState == 2)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto text = ocrText(e.id);
|
||||
if (text && lower(*text).find(q) != q.npos) return false;
|
||||
}
|
||||
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
|
||||
}
|
||||
// Short previews contain the complete text (or a placeholder for
|
||||
// textless clips). Do not decrypt image payloads just to rule them out.
|
||||
// Newlines/tabs are flattened in previews, so those queries still
|
||||
// need the original text. Exactly 240 characters may be truncated.
|
||||
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
|
||||
return true;
|
||||
try
|
||||
{
|
||||
return lower(load(e.id).text()).find(q) == q.npos;
|
||||
|
||||
@@ -13,6 +13,8 @@ struct Entry
|
||||
std::wstring id, preview, app;
|
||||
uint64_t bytes{}, at{};
|
||||
bool pinned{}, image{}, files{};
|
||||
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
|
||||
std::wstring ocrProfile = L"windows";
|
||||
};
|
||||
class History
|
||||
{
|
||||
@@ -27,6 +29,8 @@ class History
|
||||
std::vector<Entry> entries() const;
|
||||
Clip load(const std::wstring &id) const;
|
||||
void add(const Clip &, const std::wstring &app, const Settings &);
|
||||
bool saveOcr(const std::wstring &id, const std::optional<std::wstring> &, const Settings &);
|
||||
std::optional<std::wstring> ocrText(const std::wstring &id, const std::wstring &profile = L"") const;
|
||||
void remove(const std::wstring &);
|
||||
void pin(const std::wstring &);
|
||||
void clear();
|
||||
|
||||
+159
-19
@@ -31,7 +31,7 @@ HFONT font(HWND w)
|
||||
struct WindowLayout
|
||||
{
|
||||
int width{}, height{}, x{}, y{};
|
||||
bool scrolling{}, arranging{};
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||
};
|
||||
@@ -68,11 +68,51 @@ void arrangeWindow(HWND w)
|
||||
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
RECT client{}; GetClientRect(w, &client);
|
||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
struct Move { HWND child; RECT rect; };
|
||||
std::vector<Move> moves;
|
||||
for (const auto &[child, r] : layout->controls)
|
||||
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
|
||||
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
{
|
||||
int id = GetDlgCtrlID(child);
|
||||
RECT next{scale(w, r.left) - layout->x,
|
||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
||||
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
||||
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
||||
RECT current{}; GetWindowRect(child, ¤t);
|
||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
||||
}
|
||||
if (!moves.empty())
|
||||
{
|
||||
auto batch = BeginDeferWindowPos((int)moves.size());
|
||||
for (const auto &move : moves)
|
||||
{
|
||||
if (!batch) break;
|
||||
auto r = move.rect;
|
||||
batch = DeferWindowPos(batch, move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
}
|
||||
if (!batch || !EndDeferWindowPos(batch))
|
||||
for (const auto &move : moves)
|
||||
{
|
||||
auto r = move.rect;
|
||||
SetWindowPos(move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
}
|
||||
}
|
||||
layout->arranging = false;
|
||||
InvalidateRect(w, nullptr, TRUE);
|
||||
}
|
||||
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
|
||||
{
|
||||
SCROLLINFO info{sizeof(info), SIF_RANGE | SIF_PAGE};
|
||||
GetScrollInfo(w, horizontal ? SB_HORZ : SB_VERT, &info);
|
||||
int next = std::clamp(requested, info.nMin, std::max(info.nMin, info.nMax - std::max(0, (int)info.nPage - 1)));
|
||||
int &position = horizontal ? layout->x : layout->y;
|
||||
if (position == next) return;
|
||||
position = next;
|
||||
arrangeWindow(w);
|
||||
}
|
||||
void revealControl(HWND child)
|
||||
{
|
||||
@@ -118,7 +158,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
bool vertical = msg == WM_VSCROLL;
|
||||
SCROLLINFO info{sizeof(info), SIF_ALL};
|
||||
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
|
||||
int &position = vertical ? layout->y : layout->x;
|
||||
int position = vertical ? layout->y : layout->x;
|
||||
switch (LOWORD(wp))
|
||||
{
|
||||
case SB_LINEUP: position -= scale(w, 32); break;
|
||||
@@ -129,7 +169,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
case SB_BOTTOM: position = info.nMax; break;
|
||||
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
|
||||
}
|
||||
arrangeWindow(w);
|
||||
scrollWindow(w, layout, !vertical, position);
|
||||
return 0;
|
||||
}
|
||||
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
|
||||
@@ -139,8 +179,9 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
remainder += GET_WHEEL_DELTA_WPARAM(wp);
|
||||
int steps = remainder / WHEEL_DELTA;
|
||||
remainder %= WHEEL_DELTA;
|
||||
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
|
||||
arrangeWindow(w);
|
||||
if (steps)
|
||||
scrollWindow(w, layout, horizontal, (horizontal ? layout->x : layout->y) +
|
||||
steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1));
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
@@ -267,7 +308,7 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
RegisterClassW(&wc);
|
||||
UINT dpi = GetDpiForSystem();
|
||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
|
||||
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||
POINT cursor{};
|
||||
@@ -351,6 +392,9 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
||||
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
||||
if (old)
|
||||
DeleteObject(old);
|
||||
// layoutProc consumes WM_DPICHANGED, so owner-drawn rows must be resized here.
|
||||
if (layout && layout->history)
|
||||
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||
fitToMonitor(w, *(RECT *)lp);
|
||||
arrangeWindow(w);
|
||||
}
|
||||
@@ -408,7 +452,7 @@ void search(HWND w, HistoryView *v)
|
||||
auto generation = ++v->generation;
|
||||
v->pending.clear();
|
||||
auto *app = v->app;
|
||||
app->enqueue(
|
||||
app->enqueueHistorySearch(
|
||||
[w, app, q, generation]
|
||||
{
|
||||
try
|
||||
@@ -613,6 +657,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
int id = LOWORD(wp);
|
||||
if (id == 101 && HIWORD(wp) == EN_CHANGE)
|
||||
{
|
||||
// Reject an older result even during the debounce interval.
|
||||
++v->generation;
|
||||
SetTimer(w, 1, 180, nullptr);
|
||||
return 0;
|
||||
}
|
||||
@@ -720,7 +766,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (Windows 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);
|
||||
@@ -738,12 +784,6 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->pending.clear();
|
||||
++v->generation;
|
||||
return 0;
|
||||
case WM_DPICHANGED:
|
||||
{
|
||||
resizeForDpi(w, wp, lp);
|
||||
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||
return 0;
|
||||
}
|
||||
case WM_NCDESTROY:
|
||||
disposeFont(w);
|
||||
v->app->historyWindow = nullptr;
|
||||
@@ -813,6 +853,7 @@ void fillSettings(HWND w, SettingsView *v)
|
||||
check(w, 201, s.recent);
|
||||
check(w, 202, s.switchLayout);
|
||||
check(w, 208, s.writeLog);
|
||||
check(w, 209, s.screenshotAutoOpenEditor);
|
||||
check(w, 203, s.history);
|
||||
check(w, 204, s.autostart);
|
||||
check(w, 206, s.elevatedAutostart);
|
||||
@@ -863,6 +904,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.recent = checked(w, 201);
|
||||
s.switchLayout = checked(w, 202);
|
||||
s.writeLog = checked(w, 208);
|
||||
s.screenshotAutoOpenEditor = checked(w, 209);
|
||||
s.history = checked(w, 203);
|
||||
s.autostart = checked(w, 204);
|
||||
s.elevatedAutostart = checked(w, 206);
|
||||
@@ -1049,6 +1091,99 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
}
|
||||
struct OcrOptionsView { HWND parent; SettingsView *settings; };
|
||||
Settings ocrDraft(HWND w, OcrOptionsView *v)
|
||||
{
|
||||
auto draft = v->settings->draft;
|
||||
draft.useTesseract = checked(w, 501);
|
||||
draft.tesseractPath = value(w, 502);
|
||||
draft.tesseractLanguages = value(w, 503);
|
||||
validateSettings(draft);
|
||||
return draft;
|
||||
}
|
||||
LRESULT CALLBACK ocrOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *v = reinterpret_cast<OcrOptionsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
try
|
||||
{
|
||||
if (id == 507)
|
||||
{
|
||||
if ((INT_PTR)ShellExecuteW(w, L"open", L"https://github.com/UB-Mannheim/tesseract/wiki#tesseract-installer-for-windows",
|
||||
nullptr, nullptr, SW_SHOWNORMAL) <= 32)
|
||||
throw std::runtime_error("Could not open the Tesseract download page in your browser");
|
||||
return 0;
|
||||
}
|
||||
if (id == 504)
|
||||
{
|
||||
wchar_t path[32768]{};
|
||||
OPENFILENAMEW picker{sizeof(picker)};
|
||||
picker.hwndOwner = w; picker.lpstrFile = path; picker.nMaxFile = std::size(path);
|
||||
picker.lpstrFilter = L"Tesseract executable (tesseract.exe)\0tesseract.exe\0Executables\0*.exe\0";
|
||||
picker.Flags = OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR;
|
||||
if (GetOpenFileNameW(&picker)) value(w, 502, path);
|
||||
return 0;
|
||||
}
|
||||
if (id == 505 || id == IDOK)
|
||||
{
|
||||
auto draft = ocrDraft(w, v);
|
||||
if (id == 505)
|
||||
{
|
||||
draft.useTesseract = true;
|
||||
validateTesseract(draft);
|
||||
SetDlgItemTextW(w, 506, (L"Ready: " + draft.tesseractLanguages).c_str());
|
||||
return 0;
|
||||
}
|
||||
validateTesseract(draft);
|
||||
v->settings->draft.useTesseract = draft.useTesseract;
|
||||
v->settings->draft.tesseractPath = draft.tesseractPath;
|
||||
v->settings->draft.tesseractLanguages = draft.tesseractLanguages;
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||
}
|
||||
catch (const std::exception &e) { error(w, e); return 0; }
|
||||
}
|
||||
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
EnableWindow(v->parent, TRUE);
|
||||
SetForegroundWindow(v->parent);
|
||||
disposeFont(w); delete v;
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
void showOcrOptions(HWND parent, SettingsView *v)
|
||||
{
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.OcrOptions", L"Image text recognition", ocrOptionsProc, 640, 450);
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
|
||||
label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600);
|
||||
checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
|
||||
check(w, 501, v->draft.useTesseract);
|
||||
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
|
||||
edit(w, 502, 18, 118, 484);
|
||||
value(w, 502, v->draft.tesseractPath);
|
||||
button(w, L"Browse…", 504, 514, 117, 106);
|
||||
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
|
||||
edit(w, 503, 18, 188, 602);
|
||||
value(w, 503, v->draft.tesseractLanguages);
|
||||
button(w, L"Check installation", 505, 18, 228, 180);
|
||||
control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24);
|
||||
button(w, L"Download Tesseract…", 507, 18, 266, 180);
|
||||
label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408);
|
||||
label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602);
|
||||
label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602);
|
||||
button(w, L"OK", IDOK, 406, 390, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w); SetForegroundWindow(w);
|
||||
}
|
||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED)
|
||||
@@ -1084,6 +1219,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
showLogs(*v->app);
|
||||
return 0;
|
||||
}
|
||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||
if (id == 230)
|
||||
{
|
||||
collectLayout(w, v);
|
||||
@@ -1325,7 +1461,7 @@ void refreshHistory(App &app)
|
||||
search(app.historyWindow, v);
|
||||
}
|
||||
}
|
||||
void showHistory(App &app)
|
||||
void showHistory(App &app, bool visible)
|
||||
{
|
||||
if (!app.history)
|
||||
{
|
||||
@@ -1333,7 +1469,7 @@ void showHistory(App &app)
|
||||
return;
|
||||
}
|
||||
HWND foreground = GetForegroundWindow();
|
||||
if (app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
|
||||
if (visible && app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
|
||||
{
|
||||
auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA);
|
||||
if (v->items.size() > 1)
|
||||
@@ -1346,6 +1482,7 @@ void showHistory(App &app)
|
||||
{
|
||||
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
|
||||
app.historyWindow = w;
|
||||
windowLayoutState(w)->history = true;
|
||||
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
||||
label(w, L"Search clipboard history", 16, 12, 500);
|
||||
edit(w, 101, 16, 38, 728);
|
||||
@@ -1362,6 +1499,7 @@ void showHistory(App &app)
|
||||
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
||||
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
||||
}
|
||||
if (!visible) return;
|
||||
POINT cursor;
|
||||
GetCursorPos(&cursor);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
@@ -1651,6 +1789,7 @@ void showSettings(App &app, bool visible)
|
||||
label(w, names[i], 420, 48 + i * 30, 115);
|
||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||
}
|
||||
checkbox(w, L"Open editor after screen capture", 209, 420, 224, 340);
|
||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||
@@ -1674,6 +1813,7 @@ void showSettings(App &app, bool visible)
|
||||
edit(w, 243, 18, 544, 742);
|
||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
||||
button(w, L"View log…", 256, 278, 575, 220);
|
||||
button(w, L"OCR options…", 257, 518, 575, 242);
|
||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||
|
||||
+5
-1
@@ -2,8 +2,12 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.5.0";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.9";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
bool notifyUpdateAvailable(const std::wstring &version);
|
||||
std::wstring updateToastXml(const std::wstring &version);
|
||||
struct UpdateResult
|
||||
{
|
||||
std::wstring state, version, error;
|
||||
|
||||
+173
-1
@@ -4,9 +4,32 @@
|
||||
#include <commctrl.h>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
|
||||
{
|
||||
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
|
||||
return DefSubclassProc(w, message, wp, lp);
|
||||
}
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static inline bool delivery = true;
|
||||
static inline int notifications = 0;
|
||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
static bool updateTimer(App &a) {
|
||||
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
||||
App::windowProc(a.window,WM_TIMER,5,0);
|
||||
bool first=KillTimer(a.window,5)!=FALSE;
|
||||
App::windowProc(a.window,WM_TIMER,5,0);
|
||||
bool repeated=KillTimer(a.window,5)!=FALSE;
|
||||
a.updateRunning=false;
|
||||
return first && repeated && !a.updateWorker.joinable();
|
||||
}
|
||||
static std::wstring updateResult(App &a,const wchar_t *state,const wchar_t *version) {
|
||||
auto result=new UpdateResult; result->state=state; result->version=version;
|
||||
App::windowProc(a.window,UpdateMessage,0,(LPARAM)result);
|
||||
return a.notifiedUpdate;
|
||||
}
|
||||
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
||||
};
|
||||
}
|
||||
@@ -28,9 +51,26 @@ int main(int argc, char **argv) {
|
||||
{
|
||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||
ks::InputRegressionAccess::setup(app);
|
||||
ks::InputRegressionAccess::notificationSetup(app);
|
||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||
require(app.window!=nullptr,"message-only test window");
|
||||
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
|
||||
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
|
||||
ks::InputRegressionAccess::delivery=false;
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
|
||||
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
|
||||
ks::InputRegressionAccess::delivery=true;
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
|
||||
auto toast=ks::updateToastXml(L"1&2<3\"");
|
||||
require(toast.find(L"1&2<3"")!=toast.npos,"update version is XML escaped");
|
||||
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
|
||||
"toast body and action both review the update instead of installing automatically");
|
||||
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
||||
for(int size:{22,44,55}) {
|
||||
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
||||
@@ -159,6 +199,67 @@ int main(int argc, char **argv) {
|
||||
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
||||
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
||||
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
||||
for(auto shape : {ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
|
||||
std::vector<ks::ScreenshotMark> objects={{{100,50},{160,110},0},{{10,80},{100,80},0},{{10,140},{55,80},0}};
|
||||
objects[0].shape=shape;
|
||||
ks::linkScreenshotEndpoint(objects[1],false,{100,80},objects,6);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].toLink.target==objects[0].id && objects[1].to.x==100,"arrow attaches to shape boundary");
|
||||
ks::linkScreenshotEndpoint(objects[2],false,{55,80},objects,6);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[2].toLink.target==objects[1].id,"arrow attaches to another arrow body");
|
||||
auto targetId=objects[0].id,arrowId=objects[1].id;
|
||||
auto snapshot=objects; ks::ScreenshotHistory linksHistory; linksHistory.remember(objects);
|
||||
// Reverse storage order so dependency resolution cannot rely on insertion order.
|
||||
std::reverse(objects.begin(),objects.end());
|
||||
objects[2]=ks::dragScreenshotMark(objects[2],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].to.x==120 && objects[1].to.y==100 && objects[0].to.x==65 && objects[0].to.y==90,
|
||||
"shape-arrow-arrow chain updates in dependency order");
|
||||
require(linksHistory.undo(objects) && objects[1].to.x==100 && objects[2].toLink.target==arrowId,
|
||||
"undo restores geometry and generic links");
|
||||
require(linksHistory.redo(objects) && objects[0].to.x==65,"redo restores moved chain");
|
||||
auto resized=objects;
|
||||
auto corner=resized[2].to;
|
||||
resized[2]=ks::dragScreenshotMark(resized[2],ks::MarkPart::End,corner,{corner.x+20,corner.y+20},400,300);
|
||||
ks::resolveScreenshotLinks(resized);
|
||||
require(resized[1].to.x==120 && resized[1].to.y==110 && resized[0].to.y==95,
|
||||
"resizing shapes updates both levels of attached arrows");
|
||||
ks::linkScreenshotEndpoint(objects[1],true,objects[0].from,objects,6);
|
||||
require(!objects[1].fromLink.target,"cyclic arrow links are rejected");
|
||||
auto clone=ks::cloneScreenshotMark(objects[1],objects,400,300);
|
||||
require(!clone.id && !clone.fromLink.target && !clone.toLink.target,"cloned connector has independent identity and no links");
|
||||
auto endpoint=objects[1].to;
|
||||
objects.erase(objects.begin()+2); ks::resolveScreenshotLinks(objects);
|
||||
require(!objects[1].toLink.target && objects[1].to.x==endpoint.x && objects[0].toLink.target==arrowId,
|
||||
"deleting shape detaches only its dependents without moving endpoints");
|
||||
objects.push_back(snapshot[0]); objects.back().id=0; ks::resolveScreenshotLinks(objects);
|
||||
require(objects.back().id!=targetId && !objects[1].toLink.target,"new objects cannot revive deleted links");
|
||||
}
|
||||
{
|
||||
std::vector<ks::ScreenshotMark> objects={{{80,40},{80,40},0},{{10,40},{80,40},0}};
|
||||
objects[0].text=L"Target"; objects[0].fontSize=20;
|
||||
auto bounds=ks::screenshotTextBounds(objects[0]);
|
||||
ks::linkScreenshotEndpoint(objects[1],false,{bounds.left,(bounds.top+bounds.bottom)/2},objects,6);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].toLink.target==objects[0].id,"arrow attaches to text block");
|
||||
auto old=objects[1].to;
|
||||
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{30,20},400,300);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].to.x==old.x+30 && objects[1].to.y==old.y+20,"text attachment follows movement");
|
||||
objects[0].rotation=90; ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].to.x!=old.x+30 || objects[1].to.y!=old.y+20,"text attachment follows rotation");
|
||||
}
|
||||
for(bool freehand : {false,true}) {
|
||||
std::vector<ks::ScreenshotMark> objects={{{80,60},{160,80},0},{{10,140},{110,60},0}};
|
||||
objects[0].points={{80,60},{140,60},{160,80}}; objects[0].freehand=freehand;
|
||||
ks::linkScreenshotEndpoint(objects[1],false,{110,60},objects,6);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].toLink.target==objects[0].id,"arrow attaches to freehand and highlight strokes");
|
||||
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
||||
ks::resolveScreenshotLinks(objects);
|
||||
require(objects[1].to.x==130 && objects[1].to.y==80,"stroke attachment follows the original segment");
|
||||
}
|
||||
auto lastPoint=linked[2].from;
|
||||
linked.erase(linked.begin());
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
@@ -485,9 +586,61 @@ int main(int argc, char **argv) {
|
||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||
ks::showSettings(app,false);
|
||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||
ks::showHistory(app,false);
|
||||
auto history=app.historyWindow;
|
||||
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
|
||||
{
|
||||
app.history=std::make_unique<ks::History>(temp/L"history-search");
|
||||
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
|
||||
app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings);
|
||||
// This App has no general worker: a search on that queue can never finish.
|
||||
auto originalFocus=GetFocus();
|
||||
auto pumpUntil=[&](int count) {
|
||||
auto deadline=GetTickCount64()+3000;
|
||||
do {
|
||||
MSG message{};
|
||||
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true;
|
||||
Sleep(5);
|
||||
} while(GetTickCount64()<deadline);
|
||||
return false;
|
||||
};
|
||||
ks::refreshHistory(app);
|
||||
require(pumpUntil(2),"history loads independently of general worker queue");
|
||||
SetDlgItemTextW(history,101,L"згенеруй");
|
||||
require(pumpUntil(1),"typing filters history via debounce without focus change");
|
||||
SetDlgItemTextW(history,101,L"missing query");
|
||||
require(pumpUntil(0),"unmatched search clears history list");
|
||||
SetDlgItemTextW(history,101,L"");
|
||||
require(pumpUntil(2),"clearing search restores history");
|
||||
require(GetFocus()==originalFocus,"history search preserves focus");
|
||||
}
|
||||
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
||||
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
||||
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
|
||||
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
|
||||
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
|
||||
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
|
||||
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
|
||||
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
|
||||
// Reproduce a row height left over from another DPI without changing desktop settings.
|
||||
UINT historyDpi=GetDpiForWindow(history);
|
||||
for (int staleDpi : {96, 144, 192, 240}) {
|
||||
SendDlgItemMessageW(history,102,LB_SETITEMHEIGHT,0,MulDiv(88,staleDpi,96));
|
||||
SendMessageW(history,WM_DPICHANGED,MAKELONG(historyDpi,historyDpi),(LPARAM)&historyFrame);
|
||||
require(SendDlgItemMessageW(history,102,LB_GETITEMHEIGHT,0,0)==MulDiv(88,historyDpi,96),
|
||||
"DPI change updates owner-drawn history row height through layout subclass");
|
||||
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"DPI refresh preserves history list geometry");
|
||||
}
|
||||
auto settings=app.settingsWindow;
|
||||
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
|
||||
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
||||
for(auto pair:{std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
||||
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
||||
RECT first{},second{},overlap{};
|
||||
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
||||
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
||||
@@ -507,8 +660,27 @@ int main(int argc, char **argv) {
|
||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
|
||||
GetScrollInfo(settings,SB_HORZ,&scroll);
|
||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
|
||||
int controlMoves = 0;
|
||||
auto trackedControl = GetDlgItem(settings,201);
|
||||
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
|
||||
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
|
||||
controlMoves=0;
|
||||
ValidateRect(settings,nullptr);
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||
controlMoves=0;
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
||||
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||
GetClientRect(settings,&client);
|
||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
||||
|
||||
+46
-4
@@ -1,7 +1,14 @@
|
||||
#include "ocr.h"
|
||||
#include "app.h"
|
||||
#include <iostream>
|
||||
int main()
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static void scheduleOcr(App &app) { app.scheduleHistoryOcr(app.settings); }
|
||||
};
|
||||
}
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const bool tesseract = argc == 2 && std::string(argv[1]) == "--tesseract";
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
try
|
||||
{
|
||||
@@ -24,7 +31,8 @@ int main()
|
||||
auto oldFont = SelectObject(dc, font);
|
||||
SetTextColor(dc, RGB(0, 0, 0));
|
||||
SetBkColor(dc, RGB(255, 255, 255));
|
||||
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15);
|
||||
std::wstring fixture = tesseract ? L"Привіт Україно HELLO" : L"HELLO KSWITCHER";
|
||||
TextOutW(dc, 20, 40, fixture.c_str(), (int)fixture.size());
|
||||
GdiFlush();
|
||||
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
|
||||
SelectObject(dc, oldFont);
|
||||
@@ -36,11 +44,45 @@ int main()
|
||||
auto decoded = ks::decodeImage(clip);
|
||||
if (decoded.width != 800 || decoded.height != 150)
|
||||
throw std::runtime_error("WIC decode size mismatch");
|
||||
auto text = ks::recognizeText(clip);
|
||||
ks::Settings config;
|
||||
config.useTesseract = tesseract;
|
||||
auto text = ks::recognizeText(clip, config);
|
||||
std::cout << "OCR: " << ks::utf8(text) << '\n';
|
||||
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
|
||||
throw std::runtime_error("OCR fixture was not recognized");
|
||||
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n";
|
||||
if (tesseract && ks::lower(text).find(L"привіт") == std::wstring::npos)
|
||||
throw std::runtime_error("Ukrainian OCR fixture was not recognized");
|
||||
auto temp = std::filesystem::temp_directory_path() / (L"KSwitcher-auto-ocr-" + std::to_wstring(GetCurrentProcessId()));
|
||||
auto sequence = GetClipboardSequenceNumber();
|
||||
{
|
||||
ks::App app(GetModuleHandleW(nullptr), true, temp);
|
||||
app.settings.history = true;
|
||||
app.history->add(clip, L"OCR fixture", app.settings);
|
||||
auto id = app.history->entries().front().id;
|
||||
if (tesseract)
|
||||
{
|
||||
app.history->saveOcr(id, L"old Windows result", app.settings);
|
||||
app.settings.useTesseract = true;
|
||||
}
|
||||
ks::InputRegressionAccess::scheduleOcr(app);
|
||||
auto deadline = GetTickCount64() + 35000;
|
||||
while ((app.history->entries().front().ocrState == 1 ||
|
||||
app.history->entries().front().ocrProfile != ks::ocrProfile(app.settings)) &&
|
||||
GetTickCount64() < deadline) Sleep(20);
|
||||
if (app.history->search(L"HELLO").size() != 1)
|
||||
throw std::runtime_error("Background OCR did not make the image searchable");
|
||||
ks::History restored(temp / L"history");
|
||||
if (!restored.ocrText(id) || restored.search(L"HELLO").size() != 1)
|
||||
throw std::runtime_error("Background OCR did not persist its result");
|
||||
if (tesseract && (restored.search(L"привіт").size() != 1 || !restored.search(L"old Windows result").empty()))
|
||||
throw std::runtime_error("Engine change did not replace cached Windows OCR with Ukrainian text");
|
||||
if (GetClipboardSequenceNumber() != sequence)
|
||||
throw std::runtime_error("Background OCR changed the clipboard");
|
||||
std::cout << "PASS automatic background OCR, persisted search and unchanged clipboard\n";
|
||||
}
|
||||
std::filesystem::remove_all(temp);
|
||||
std::cout << (tesseract ? "PASS optional Tesseract, mixed Ukrainian/English and cache migration\n" :
|
||||
"PASS native Windows OCR, DIB decoding and async result retrieval\n");
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -35,30 +35,35 @@ function Select-Region($Window) {
|
||||
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
|
||||
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
|
||||
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
|
||||
Wait-For { [ScreenshotProcessTest]::Title($Window) -match 'Screenshot #' } 'Selection did not become an editor.'
|
||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).'
|
||||
}
|
||||
function Close-Editor($Window) {
|
||||
Send $Window 0x10
|
||||
Wait-For { @([ScreenshotProcessTest]::Windows($script:process.Id,'#32770')).Count -eq 1 } 'Missing close confirmation.'
|
||||
$dialog=[ScreenshotProcessTest]::Windows($script:process.Id,'#32770')[0]
|
||||
Send $dialog (0x400+102) 6 # TDM_CLICK_BUTTON / IDYES; no clipboard writes.
|
||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Editor did not close.'
|
||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.'
|
||||
}
|
||||
function Latest-Token {
|
||||
(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName
|
||||
}
|
||||
$process=$null
|
||||
try {
|
||||
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
|
||||
$first=(Capture-Windows)[0]; Select-Region $first
|
||||
$secondLaunch=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second launch did not hand off.' }
|
||||
Wait-For { (Capture-Windows).Count -eq 2 } 'Second selection did not open in same process.'
|
||||
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]; Select-Region $second
|
||||
Select-Region (Capture-Windows)[0]
|
||||
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
|
||||
$token=Latest-Token
|
||||
$process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.'
|
||||
$first=(Capture-Windows)[0]
|
||||
$secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
||||
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' }
|
||||
Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.'
|
||||
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]
|
||||
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
|
||||
Close-Editor $first
|
||||
if ($process.HasExited) { throw 'Process exited with an editor open.' }
|
||||
Close-Editor $second
|
||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
|
||||
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
|
||||
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
|
||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Redirect HKCU and the shortcut destination; never replace the user's registration.
|
||||
#include "../src/screenshot_notifications.h"
|
||||
#include <shlobj.h>
|
||||
#include <iostream>
|
||||
static std::wstring programs;
|
||||
static std::wstring activationExecutable, activationCommand;
|
||||
static int activationCalls{};
|
||||
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
||||
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
||||
{
|
||||
++activationCalls; activationExecutable=path; activationCommand=command;
|
||||
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
||||
}
|
||||
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
||||
{
|
||||
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
||||
if(!*out) return E_OUTOFMEMORY;
|
||||
wcscpy(*out,programs.c_str()); return S_OK;
|
||||
}
|
||||
#define SHGetKnownFolderPath testKnownFolder
|
||||
#define CreateProcessW testCreateProcess
|
||||
#include "../src/screenshot_activation.cpp"
|
||||
#undef SHGetKnownFolderPath
|
||||
#undef CreateProcessW
|
||||
int main()
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
auto suffix=std::to_wstring(GetCurrentProcessId());
|
||||
auto keyPath=L"Software\\KSwitcherRegistrationTest-"+suffix;
|
||||
auto directory=std::filesystem::temp_directory_path()/(L"KSwitcherRegistrationTest-"+suffix);
|
||||
std::filesystem::create_directories(directory); programs=directory.wstring();
|
||||
HKEY isolated{}; int result=0;
|
||||
try {
|
||||
if(RegCreateKeyExW(HKEY_CURRENT_USER,keyPath.c_str(),0,nullptr,0,KEY_ALL_ACCESS,nullptr,&isolated,nullptr)!=ERROR_SUCCESS ||
|
||||
RegOverridePredefKey(HKEY_CURRENT_USER,isolated)!=ERROR_SUCCESS) throw std::runtime_error("registry isolation failed");
|
||||
ks::registerScreenshotNotifications();
|
||||
wchar_t command[32768]{}; DWORD size=sizeof(command);
|
||||
auto path=L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32";
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,path,nullptr,RRF_RT_REG_SZ,nullptr,command,&size)!=ERROR_SUCCESS)
|
||||
throw std::runtime_error("COM server was not registered");
|
||||
auto helper=std::filesystem::path(ks::executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
if(command!=L"\""+helper.wstring()+L"\" --toast-activated") throw std::runtime_error("COM server targets wrong executable");
|
||||
Microsoft::WRL::ComPtr<IShellLinkW> link;
|
||||
if(FAILED(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)))) throw std::runtime_error("read shortcut");
|
||||
Microsoft::WRL::ComPtr<IPersistFile> file; link.As(&file);
|
||||
if(FAILED(file->Load((directory/L"KSwitcher Screenshot.lnk").c_str(),STGM_READ))) throw std::runtime_error("shortcut missing");
|
||||
wchar_t target[32768]{}; link->GetPath(target,32768,nullptr,SLGP_RAWPATH);
|
||||
if(target!=helper.wstring()) throw std::runtime_error("shortcut targets wrong executable");
|
||||
Microsoft::WRL::ComPtr<IPropertyStore> properties; link.As(&properties);
|
||||
PROPVARIANT id{}; properties->GetValue(PKEY_AppUserModel_ID,&id);
|
||||
bool valid=id.vt==VT_LPWSTR && std::wstring(id.pwszVal)==L"HrynCo.KSwitcher.Screenshot";
|
||||
PropVariantClear(&id);
|
||||
if(!valid) throw std::runtime_error("shortcut identity missing");
|
||||
properties->GetValue(PKEY_AppUserModel_ToastActivatorCLSID,&id);
|
||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
||||
ks::Activator activator(helper.wstring(),false);
|
||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
||||
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
||||
if(activationCalls!=1 || activationExecutable!=main || activationCommand!=L"\""+main+L"\" --check-update")
|
||||
throw std::runtime_error("update toast must open review, never install automatically");
|
||||
if(activator.Activate(L"other-app",L"check-update",nullptr,0)!=E_INVALIDARG ||
|
||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"--install",nullptr,0)!=E_INVALIDARG || activationCalls!=1)
|
||||
throw std::runtime_error("invalid notification activation must not launch a process");
|
||||
const wchar_t *token=L"12345678-1234-1234-1234-123456789abc";
|
||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",token,nullptr,0);
|
||||
if(activationCalls!=2 || activationExecutable!=helper.wstring() || activationCommand.find(token)==activationCommand.npos)
|
||||
throw std::runtime_error("screenshot activation must retain its existing route");
|
||||
std::cout<<"Passed isolated screenshot registration checks\n";
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
||||
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
||||
if(isolated) RegCloseKey(isolated);
|
||||
RegDeleteTreeW(HKEY_CURRENT_USER,keyPath.c_str());
|
||||
std::filesystem::remove_all(directory);
|
||||
CoUninitialize(); return result;
|
||||
}
|
||||
+203
-2
@@ -4,6 +4,7 @@
|
||||
#include <iostream>
|
||||
int main()
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
try {
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
@@ -21,6 +22,8 @@ int main()
|
||||
"editor is an independent Alt+Tab application window");
|
||||
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||
"new screenshot editor starts with the pencil tool");
|
||||
for(int size:{32,48,64,96}) {
|
||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||
ICONINFO info{};
|
||||
@@ -30,16 +33,58 @@ int main()
|
||||
if(info.hbmColor) DeleteObject(info.hbmColor);
|
||||
DestroyCursor(cursor);
|
||||
}
|
||||
ks::Surface cursorPreview(784,448);
|
||||
PatBlt(cursorPreview.dc,0,0,784,448,WHITENESS);
|
||||
int row=0;
|
||||
for(int size:{32,48,64,96}) {
|
||||
int column=0;
|
||||
for(int tool:ks::CursorTools) {
|
||||
auto cursor=ks::makeToolCursor(tool,size); ICONINFO info{};
|
||||
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"tool cursors support standard and high DPI");
|
||||
require(info.xHotspot==UINT(MulDiv(7,size,32)) && info.yHotspot==info.xHotspot,"tool cursor hotspot stays on crosshair center");
|
||||
BITMAP mask{}; GetObjectW(info.hbmMask,sizeof(mask),&mask);
|
||||
require(mask.bmWidth==size && mask.bmHeight==size*2,"cursor transparency mask matches requested DPI");
|
||||
if(column%2) {
|
||||
RECT tile{column*112,row*112,(column+1)*112,(row+1)*112};
|
||||
auto dark=CreateSolidBrush(RGB(40,40,40)); FillRect(cursorPreview.dc,&tile,dark); DeleteObject(dark);
|
||||
}
|
||||
DrawIconEx(cursorPreview.dc,column*112,row*112,cursor,size,size,0,nullptr,DI_NORMAL);
|
||||
if(info.hbmMask) DeleteObject(info.hbmMask);
|
||||
if(info.hbmColor) DeleteObject(info.hbmColor);
|
||||
DestroyCursor(cursor); ++column;
|
||||
}
|
||||
++row;
|
||||
}
|
||||
auto dib=ks::screenshotClip(cursorPreview.dc,784,448,{}).formats[0].data;
|
||||
BITMAPFILEHEADER bmp{}; bmp.bfType=0x4d42; bmp.bfOffBits=sizeof(bmp)+sizeof(BITMAPINFOHEADER); bmp.bfSize=DWORD(sizeof(bmp)+dib.size());
|
||||
ks::Bytes preview(sizeof(bmp)); memcpy(preview.data(),&bmp,sizeof(bmp)); preview.insert(preview.end(),dib.begin(),dib.end());
|
||||
ks::atomicWrite(L"build/cursor-preview.bmp",preview);
|
||||
for(int tool:{27,26,21,10,11,22,23,24,25,19}) {
|
||||
SendMessageW(window,WM_COMMAND,tool,0);
|
||||
HCURSOR expected=nullptr;
|
||||
if(tool==27) expected=LoadCursorW(nullptr,IDC_ARROW);
|
||||
else if(tool==26) expected=LoadCursorW(nullptr,IDC_IBEAM);
|
||||
else if(tool==21) expected=c.pencilCursor;
|
||||
else for(size_t i=0;i<std::size(ks::CursorTools);++i) if(ks::CursorTools[i]==tool) expected=c.drawingCursors[i];
|
||||
require(expected && ks::actionCursor(c,{-1,ks::MarkPart::None},true)==expected,"each tool uses its own drawing cursor");
|
||||
require(ks::actionCursor(c,{-1,ks::MarkPart::None},false)==LoadCursorW(nullptr,IDC_ARROW),"outside canvas uses normal pointer");
|
||||
c.marks={{{20,20},{120,20},0}};
|
||||
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
|
||||
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
|
||||
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEWE),"endpoint resize overrides drawing tool");
|
||||
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
|
||||
}
|
||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
|
||||
RECT client{}; GetClientRect(window,&client);
|
||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||
ks::updateStatus(c);
|
||||
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
|
||||
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"default tool hint is shown");
|
||||
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
||||
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
||||
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
||||
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
|
||||
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"button leave restores contextual hint");
|
||||
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
||||
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
||||
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
||||
@@ -93,6 +138,124 @@ int main()
|
||||
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
|
||||
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
|
||||
c.hoveredPart=ks::MarkPart::None;
|
||||
// Drag the two badges in turn, including a click near the attached endpoint.
|
||||
for(bool freehand : {false,true}) for(auto ends : {ks::LineEnds::End,ks::LineEnds::None}) {
|
||||
c.marks={{{60,80},{60,80},1},{{230,80},{230,80},2},{{60,80},{230,80},0}};
|
||||
auto &connector=c.marks[2]; connector.fromNumber=1; connector.toNumber=2;
|
||||
connector.ends=ends; connector.freehand=freehand;
|
||||
if(freehand) connector.points={{60,80},{140,80},{230,80}};
|
||||
ks::resolveScreenshotLinks(c.marks); c.selected=-1; c.selectedLabel=false;
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
LONG 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));
|
||||
};
|
||||
for(int badge : {0,1,0,1}) {
|
||||
auto origin=c.marks[badge].from;
|
||||
// The second badge's inner edge overlaps the connector endpoint hit area.
|
||||
if(badge==1) origin.x-=12;
|
||||
mouse(WM_LBUTTONDOWN,origin);
|
||||
require(c.selected==badge && c.part==ks::MarkPart::Body,"click on linked badge selects badge instead of connector endpoint");
|
||||
mouse(WM_MOUSEMOVE,{origin.x,origin.y+20});
|
||||
mouse(WM_LBUTTONUP,{origin.x,origin.y+20});
|
||||
require(c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,"alternating badge drags preserve both connector links");
|
||||
auto start=c.marks[0].from,end=c.marks[1].from;
|
||||
require(std::abs(std::hypot(double(c.marks[2].from.x-start.x),double(c.marks[2].from.y-start.y))-15)<1 &&
|
||||
std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.y))-15)<1,
|
||||
"connector endpoints follow both badge rims");
|
||||
}
|
||||
// Selecting a linked connector (also the first click of a double-click)
|
||||
// must not detach it before opening its caption editor.
|
||||
for(int entry : {0,1,2}) {
|
||||
c.selected=-1;
|
||||
POINT body{110,c.marks[0].from.y};
|
||||
if(freehand) body={(c.marks[2].points[0].x+c.marks[2].points[1].x)/2,
|
||||
(c.marks[2].points[0].y+c.marks[2].points[1].y)/2};
|
||||
mouse(WM_LBUTTONDOWN,body); mouse(WM_LBUTTONUP,body);
|
||||
require(c.selected==2 && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
||||
"clicking a connector without moving preserves both links");
|
||||
if(entry==0) { mouse(WM_LBUTTONDBLCLK,body); mouse(WM_LBUTTONUP,body); }
|
||||
else if(entry==1) SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
||||
else SendMessageW(window,WM_COMMAND,35,0);
|
||||
require(c.inlineText && c.editingLabel,"linked connector caption opens through every entry point");
|
||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Linked caption");
|
||||
ks::finishInlineText(window,c,true);
|
||||
require(c.marks[2].label==L"Linked caption" && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
||||
"adding or editing a caption preserves both connector links");
|
||||
}
|
||||
c.selected=2;
|
||||
require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click");
|
||||
auto outside=c.marks[2].to; outside.x-=4;
|
||||
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
|
||||
}
|
||||
{
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
LONG 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));
|
||||
};
|
||||
SendMessageW(window,WM_COMMAND,27,0); c.marks.clear();
|
||||
mouse(WM_LBUTTONDOWN,{40,160}); mouse(WM_MOUSEMOVE,{100,190}); mouse(WM_LBUTTONUP,{100,190});
|
||||
require(c.marks.empty() && !c.dragging && c.selected==-1,"pointer drag on empty canvas never adds an object");
|
||||
for(int tool : {11,22}) for(int target : {0,1,2}) {
|
||||
if(target==0) c.marks={{{55,160},{55,160},1},{{255,160},{255,160},2}};
|
||||
else {
|
||||
c.marks={{{30,140},{80,190},0},{{230,140},{280,190},0}};
|
||||
if(target==1) for(auto &mark:c.marks) mark.shape=ks::ScreenshotShape::Rectangle;
|
||||
else for(auto &mark:c.marks) mark.from.y=mark.to.y=160;
|
||||
}
|
||||
SendMessageW(window,WM_COMMAND,tool,0);
|
||||
BYTE savedKeys[256]{},drawingKeys[256]{};
|
||||
require(GetKeyboardState(savedKeys)!=FALSE,"read thread keyboard state");
|
||||
std::copy(savedKeys,savedKeys+256,drawingKeys); drawingKeys[VK_CONTROL]=0x80;
|
||||
require(SetKeyboardState(drawingKeys)!=FALSE,"set thread-local Ctrl for drawing test");
|
||||
mouse(WM_LBUTTONDOWN,{55,160}); mouse(WM_MOUSEMOVE,{255,160}); mouse(WM_LBUTTONUP,{255,160});
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==3,"drawing over existing objects creates a connector with Ctrl");
|
||||
auto &connector=c.marks[2];
|
||||
require(target==0?(connector.fromNumber==1 && connector.toNumber==2):
|
||||
(connector.fromLink.target==c.marks[0].id && connector.toLink.target==c.marks[1].id),
|
||||
"new arrow or line automatically attaches both endpoints to targets");
|
||||
require(connector.ends==(tool==11?c.arrowEnds:ks::LineEnds::None),"arrow and straight-line tools retain their end styles");
|
||||
auto before=connector.from;
|
||||
SendMessageW(window,WM_KEYDOWN,'V',0);
|
||||
require(c.pointer && !c.arrow && !c.freehand,"V selects pointer mode");
|
||||
POINT body=target==0?POINT{55,160}:target==1?POINT{30,175}:POINT{30,160};
|
||||
mouse(WM_LBUTTONDOWN,body); mouse(WM_MOUSEMOVE,{body.x,body.y-20}); mouse(WM_LBUTTONUP,{body.x,body.y-20});
|
||||
require(c.selected==0 && c.marks.size()==3 && c.marks[2].from.y<before.y,
|
||||
"pointer moves the target and the new connector follows without adding objects");
|
||||
mouse(WM_LBUTTONDOWN,{150,220}); mouse(WM_LBUTTONUP,{150,220});
|
||||
require(c.selected==-1 && c.marks.size()==3,"pointer click on empty space only clears selection");
|
||||
}
|
||||
}
|
||||
for(bool manualAngle : {false,true}) {
|
||||
c.marks={{{40,140},{180,60},0}}; c.selected=0;
|
||||
c.marks[0].fontSize=18; c.marks[0].fontWeight=FW_BOLD;
|
||||
c.marks[0].rotation=31; c.marks[0].labelRotationManual=manualAngle;
|
||||
SendMessageW(window,WM_COMMAND,35,0);
|
||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Sloped caption");
|
||||
ks::finishInlineText(window,c,true);
|
||||
auto captionAngle=ks::screenshotArrowLabel(c.marks[0]).rotation;
|
||||
require(captionAngle!=0,"caption retains its automatic or manual angle");
|
||||
SendMessageW(window,WM_COMMAND,26,0);
|
||||
LONG x=c.view.left+MulDiv(220,c.view.right-c.view.left,c.image->width);
|
||||
LONG y=c.view.top+MulDiv(190,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(x,y));
|
||||
require(c.inlineText && !c.editingLabel,"standalone text starts independently of caption");
|
||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Independent text");
|
||||
ks::finishInlineText(window,c,true);
|
||||
require(c.marks.size()==2 && c.marks[1].isText() && c.marks[1].rotation==0,
|
||||
"new standalone text does not inherit caption rotation");
|
||||
require(c.marks[1].fontSize==18 && c.marks[1].fontWeight==FW_BOLD,
|
||||
"new text still inherits font styling");
|
||||
require(ks::screenshotArrowLabel(c.marks[0]).rotation==captionAngle,"new text does not change caption angle");
|
||||
c.marks[1].rotation=22; c.selected=1;
|
||||
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited standalone text");
|
||||
ks::finishInlineText(window,c,true);
|
||||
require(c.marks[1].rotation==22,"editing existing rotated text preserves its own angle");
|
||||
SendMessageW(window,WM_COMMAND,27,0);
|
||||
}
|
||||
std::vector<ks::Mark> nudgeMarks;
|
||||
nudgeMarks.push_back({{60,60},{60,60},1});
|
||||
nudgeMarks.push_back({{60,60},{120,100},0});
|
||||
@@ -162,7 +325,45 @@ int main()
|
||||
require(IsWindow(window),"Escape does not close the screenshot editor");
|
||||
SendMessageW(window,WM_CLOSE,0,0);
|
||||
require(!IsWindow(window),"copied screenshot closes without confirmation");
|
||||
static bool published=false;
|
||||
static bool notificationAccepted=false;
|
||||
static HWND notificationWindow{};
|
||||
static bool expectHidden=false;
|
||||
app.notify=[](const ks::Clip &) {
|
||||
if(!published) throw std::runtime_error("notification preceded clipboard publication");
|
||||
if(expectHidden && IsWindowVisible(notificationWindow))
|
||||
throw std::runtime_error("selection overlay is still visible when notification is sent");
|
||||
return notificationAccepted;
|
||||
};
|
||||
for(bool autoOpen : {true,false}) for (bool accepted : {true, false}) {
|
||||
app.autoOpenEditor=autoOpen;
|
||||
published=false;
|
||||
notificationAccepted=accepted;
|
||||
ks::Capture selection(app);
|
||||
selection.image=std::make_unique<ks::Surface>(100,100);
|
||||
PatBlt(selection.image->dc,0,0,100,100,WHITENESS);
|
||||
selection.start={10,20}; selection.end={70,60};
|
||||
auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection);
|
||||
notificationWindow=overlay; expectHidden=!autoOpen;
|
||||
ShowWindow(overlay,SW_SHOWNOACTIVATE);
|
||||
ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD {
|
||||
auto pixels=ks::decodeImage(clip);
|
||||
if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection");
|
||||
published=true; return 0;
|
||||
});
|
||||
if(!autoOpen && accepted) {
|
||||
require(published && !IsWindow(overlay),"notification-first closes selection after successful submission");
|
||||
continue;
|
||||
}
|
||||
require(published && selection.copied && IsWindowVisible(overlay) && selection.editing,
|
||||
"selection opens editor whether notification is accepted or rejected");
|
||||
require(selection.image->width==60 && selection.image->height==40,
|
||||
"automatically opened editor contains the selected region");
|
||||
SendMessageW(overlay,WM_CLOSE,0,0);
|
||||
require(!IsWindow(overlay),"automatically copied screenshot closes without confirmation");
|
||||
}
|
||||
DestroyWindow(app.window); app.window=nullptr;
|
||||
CoUninitialize();
|
||||
std::cout<<"Passed screenshot editor UI integration checks\n";
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||
}
|
||||
|
||||
@@ -430,6 +430,13 @@ int main(int argc, char **argv)
|
||||
auto invalid = settings;
|
||||
auto captureSettings = settings;
|
||||
captureSettings.screenshot = L"Ctrl+Shift+F9";
|
||||
require(!captureSettings.screenshotAutoOpenEditor,"notification-first capture is the default");
|
||||
captureSettings.screenshotAutoOpenEditor=true;
|
||||
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
|
||||
"explicit automatic editor preference survives settings roundtrip");
|
||||
captureSettings.screenshotAutoOpenEditor=false;
|
||||
require(!ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
|
||||
"notification-first preference survives settings roundtrip");
|
||||
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
|
||||
"screen capture shortcut roundtrip");
|
||||
captureSettings.screenshot = captureSettings.clipboard;
|
||||
@@ -461,6 +468,75 @@ int main(int argc, char **argv)
|
||||
history.clear();
|
||||
require(history.entries().empty(), "clear history");
|
||||
}
|
||||
{
|
||||
ks::History history(temp / L"search");
|
||||
history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings);
|
||||
history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings);
|
||||
require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview");
|
||||
require(history.search(L"first\nsecond").size() == 1, "search retains original newlines");
|
||||
require(history.search(L"second\tthird").size() == 1, "search retains original tabs");
|
||||
require(history.search(L"absent").empty(), "short previews reject unmatched queries");
|
||||
require(history.search(L"FIRST").size() == 1, "short preview search ignores case");
|
||||
}
|
||||
{
|
||||
auto folder = temp / L"ocr-history";
|
||||
ks::History history(folder);
|
||||
ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}};
|
||||
history.add(image, L"OCR test", settings);
|
||||
auto id = history.entries().front().id;
|
||||
require(history.entries().front().ocrState == 1, "new images await background OCR");
|
||||
require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart");
|
||||
auto original = ks::encodeClip(history.load(id));
|
||||
auto beforeBytes = history.entries().front().bytes;
|
||||
require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR");
|
||||
require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size");
|
||||
require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats");
|
||||
require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten");
|
||||
ks::History restored(folder);
|
||||
require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable");
|
||||
require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart");
|
||||
auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024);
|
||||
require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted");
|
||||
restored.remove(id);
|
||||
require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar");
|
||||
require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image");
|
||||
restored.add(image, L"OCR test", settings);
|
||||
id = restored.entries().front().id;
|
||||
restored.saveOcr(id, L"", settings);
|
||||
require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result");
|
||||
restored.clear();
|
||||
require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars");
|
||||
restored.add(image, L"OCR test", settings);
|
||||
id = restored.entries().front().id;
|
||||
restored.saveOcr(id, std::nullopt, settings);
|
||||
require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart");
|
||||
restored.saveOcr(id, L"manual retry", settings);
|
||||
require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR");
|
||||
restored.add(ks::textClip(L"newer"), L"test", settings);
|
||||
auto limited = settings; limited.maxItems = 1;
|
||||
restored.add(ks::textClip(L"newest"), L"test", limited);
|
||||
require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar");
|
||||
}
|
||||
{
|
||||
ks::Settings options;
|
||||
require(!options.useTesseract, "Tesseract is opt-in by default");
|
||||
options.useTesseract = true;
|
||||
options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe";
|
||||
options.tesseractLanguages = L"ukr+eng+rus";
|
||||
auto restored = ks::decodeSettings(ks::encodeSettings(options), {});
|
||||
require(restored.useTesseract && restored.tesseractPath == options.tesseractPath &&
|
||||
restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip");
|
||||
options.tesseractLanguages = L"ukr+../eng";
|
||||
rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected");
|
||||
options.tesseractLanguages = L"ukr++eng";
|
||||
rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected");
|
||||
options.tesseractLanguages = L"ukr+eng";
|
||||
options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe";
|
||||
rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure");
|
||||
options.useTesseract = false;
|
||||
ks::validateTesseract(options);
|
||||
require(true, "Windows OCR works without optional installation");
|
||||
}
|
||||
// Test-owned directory only; never touch the user's clipboard or stored history.
|
||||
std::filesystem::remove_all(temp);
|
||||
CoUninitialize();
|
||||
|
||||
Reference in New Issue
Block a user