Compare commits

..

9 Commits

29 changed files with 1129 additions and 128 deletions
+6 -6
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.5.0 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.1 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_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.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.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)
+43 -7
View File
@@ -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.
[![Support KSwitcher development — buy the developer a coffee](resources/support/donate-en.png)](https://send.monobank.ua/jar/88h2TddKfS)
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
## Run
@@ -71,9 +75,26 @@ 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 **Pointer (V)**: 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.
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 +104,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 +140,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 +173,9 @@ 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.
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 +194,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
@@ -193,8 +228,9 @@ field into view; Shift+wheel scrolls horizontally.
- 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.
+16 -21
View File
@@ -1,28 +1,23 @@
# Latest Windows release
## v0.4.1 — 2026-10-28
## v0.6.6 — 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
- Add **Pointer (V)** as the initial editor tool. Select and move objects, edit geometry and thickness with handles, and clear selection by clicking empty space. Pointer never creates objects on empty space.
- Preserve **Ctrl+drag** for drawing over existing objects and automatically linking connector endpoints. Add clearer Ctrl hints to the Arrow and Straight line buttons and status bar.
- Fix new standalone text inheriting the angle of an arrow caption. New text reuses typography only and starts horizontally; editing existing rotated text preserves its own angle.
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
176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. UI coverage includes Pointer interactions, Ctrl drawing between markers/shapes/arrows, and standalone text after automatic or manually rotated arrow captions.
**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 the resident,
configurator and screenshot executables, a release manifest, offline dictionaries, documentation
and license notices; no personal settings or clipboard history.
Build and package:
@@ -31,5 +26,5 @@ Build and package:
.\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
View File
@@ -1 +1 @@
7b9f07168023d2ad5c71672d0e699144bc15b11175eb05a7149b72b8be4f90b4 KSwitcher-win-x64.zip
cb711ace317e60054f6498436f02bd66f10adebfe9362b8d1c106569cd27a600 KSwitcher-win-x64.zip
+15 -7
View File
@@ -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/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,19 +43,27 @@ 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/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/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' }
+3 -3
View File
@@ -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,6,0
PRODUCTVERSION 0,6,6,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.6"
END
END
BLOCK "VarFileInfo"
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+22
View File
@@ -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

+22
View File
@@ -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

+11 -3
View File
@@ -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>
@@ -1253,8 +1254,9 @@ 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)
{
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");
@@ -1273,7 +1275,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 +1339,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;
}
@@ -1452,6 +1456,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 +1477,7 @@ int App::execute()
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
return (int)msg.wParam;
}
} // namespace ks
+2 -2
View File
@@ -85,7 +85,7 @@ class App
std::thread worker;
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate;
std::wstring availableUpdate, notifiedUpdate;
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
@@ -99,7 +99,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);
+2 -1
View File
@@ -48,6 +48,7 @@ struct Settings
bool elevatedAutostart = false;
bool autoCorrect = false;
bool writeLog = true;
bool screenshotAutoOpenEditor = false;
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
@@ -59,7 +60,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"};
+206 -42
View File
@@ -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);
@@ -126,8 +129,9 @@ struct Capture
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=true, freehand=false;
bool straight{}, textTool{};
Mark textDefaults{};
ScreenshotShape shape=ScreenshotShape::None;
@@ -160,7 +164,7 @@ struct Capture
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);
if (font) DeleteObject(font);
@@ -195,7 +199,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 +207,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 +253,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 +284,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);
}
@@ -534,7 +549,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 +588,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];
@@ -596,7 +620,7 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
}
void captureCursor(HWND w, Capture &c)
{
LPCWSTR cursor=IDC_CROSS;
LPCWSTR cursor=c.editing && c.pointer?IDC_ARROW:IDC_CROSS;
if(c.editing)
{
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
@@ -684,7 +708,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 +724,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 +785,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 +825,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 +877,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 +904,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 +959,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 +968,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,7 +984,7 @@ 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;
@@ -973,6 +1020,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 +1080,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 +1149,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 +1380,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 +1490,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 +1583,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 +1619,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 +1639,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 +1661,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
View File
@@ -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);
+145
View File
@@ -0,0 +1,145 @@
#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 || !validScreenshotToken(args)) return E_INVALIDARG;
auto path=target; auto command=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,&registration);
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,&registration);
factory->Release();
return SUCCEEDED(result)?registration:0;
}
}
+4 -2
View File
@@ -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
View File
@@ -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;
}
+185
View File
@@ -0,0 +1,185 @@
#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"&amp;"; else if(ch==L'\"') result+=L"&quot;"; else if(ch==L'<') result+=L"&lt;"; 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,&notifier));
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; }
}
+11
View File
@@ -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 &);
}
+3 -1
View File
@@ -151,6 +151,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();
@@ -205,7 +206,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));
+22 -11
View File
@@ -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,9 +68,18 @@ 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;
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);
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x,
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0),
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0),
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0),
SWP_NOACTIVATE | SWP_NOZORDER);
}
layout->arranging = false;
InvalidateRect(w, nullptr, TRUE);
}
@@ -351,6 +360,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);
}
@@ -738,12 +750,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 +819,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 +870,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);
@@ -1325,7 +1333,7 @@ void refreshHistory(App &app)
search(app.historyWindow, v);
}
}
void showHistory(App &app)
void showHistory(App &app, bool visible)
{
if (!app.history)
{
@@ -1333,7 +1341,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 +1354,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 +1371,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 +1661,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,
+2 -1
View File
@@ -2,8 +2,9 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.5.0";
inline constexpr wchar_t AppVersion[] = L"0.6.6";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
struct UpdateResult
{
std::wstring state, version, error;
+106 -1
View File
@@ -7,6 +7,20 @@
namespace ks {
struct InputRegressionAccess {
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); }
};
}
@@ -31,6 +45,12 @@ int main(int argc, char **argv) {
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
require(app.window!=nullptr,"message-only test window");
require(ks::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::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");
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 +179,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 +566,33 @@ 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");
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");
+17 -12
View File
@@ -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.' }
+55
View File
@@ -0,0 +1,55 @@
// 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 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
#include "../src/screenshot_activation.cpp"
#undef SHGetKnownFolderPath
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.
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;
}
+159 -2
View File
@@ -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)};
@@ -35,11 +36,11 @@ int main()
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 +94,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 +281,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; }
}
+7
View File
@@ -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;