Compare commits

..

6 Commits

26 changed files with 1258 additions and 132 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.1 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.9 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
@@ -9,7 +9,7 @@ add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorre
target_include_directories(kscore PUBLIC src PRIVATE vendor) target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) target_link_libraries(kscore PUBLIC 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) add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP) target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore) target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
@@ -18,9 +18,9 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore) target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp) add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore) target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore) target_link_libraries(KSwitcherIntegration PRIVATE kscore)
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) add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore) target_link_libraries(KSwitcherInputTests PRIVATE kscore)
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES) target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc) add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
@@ -36,7 +36,7 @@ if(MSVC)
else() else()
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
endif() endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp) add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore) target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC) if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>") set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
+40 -3
View File
@@ -90,8 +90,14 @@ broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot. 40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window. In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
other properties using handles and controls. Clicking empty space clears selection; dragging empty
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
cursors scale with display DPI and use a contrasting outline for light and dark images.
To annotate: **N** places sequential numbered red markers with a click, and **A** draws To annotate: **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; 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 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. (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 Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
@@ -101,6 +107,14 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
gets the smallest available positive number, just like a newly placed number: gaps left by deletion gets the smallest available positive number, just like a newly placed number: gaps left by deletion
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links. are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
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 The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
and its keyboard shortcut. and its keyboard shortcut.
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
@@ -129,6 +143,8 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel; 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 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. 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. 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 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 the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
@@ -162,6 +178,11 @@ Select **Update** in the tray menu to check the stable Releases of
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases). [this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
A background check runs shortly after startup and every 6 hours while KSwitcher is running. 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. It reports each available version once per session and never installs automatically.
Update availability uses the same Windows toast notification channel as screenshot notifications.
Click the notification or **Review update** to check the latest release and confirm installation;
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
Failed notification submissions are logged and retried after five minutes instead of being marked
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time. Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
Confirm the offered version to download and install it. The updater uses the existing Confirm the offered version to download and install it. The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64 `KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
@@ -207,8 +228,24 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
stylesheet rules are not reproduced; preview text uses the list's colors for readability. stylesheet rules are not reproduced; preview text uses the list's colors for readability.
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport - Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
metadata (including Snipping Tool's repeated clipboard publication). metadata (including Snipping Tool's repeated clipboard publication).
- Windows OCR, entirely native and offline. Right-click an image in history and select - New images are recognized automatically in a separate background worker using native, offline
**Copy text from image**. Requires a Windows OCR language installed for the user profile. Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search,
without changing the image or clipboard. Completed results (including empty text) are reused;
pending work resumes after restart. Older images without OCR are processed once in the background,
starting with the newest entries.
Right-click an image and select **Copy text from image** to copy the saved text or explicitly
retry recognition. Requires a Windows OCR language installed for the user profile; automatic
failures are logged quietly. OCR sidecars count toward the history size limit and are removed
with their images.
- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains
the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package
separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable
path blank to detect a standard installation, or select `tesseract.exe`; its neighboring
`libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation**
validates the selected models. **Download Tesseract…** opens the Windows installer page in your
browser. No packages or models are downloaded automatically.
Tesseract processes screenshots locally in memory with a 30-second recognition deadline.
Changing engine, executable path or languages refreshes cached image text in the background.
- History exclusion flags and excluded processes, pause/resume, count/size limits. - History exclusion flags and excluded processes, pause/resume, count/size limits.
- Per-user Windows DPAPI encryption for both history payloads and the preview index. - Per-user Windows DPAPI encryption for both history payloads and the preview index.
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export. - Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
Binary file not shown.
+14 -20
View File
@@ -1,33 +1,27 @@
# Latest Windows release # Latest Windows release
## v0.6.3 — 2026-10-29 ## v0.6.9 — 2026-10-29
- Hide the fullscreen capture selection before submitting a notification when automatic editor opening is disabled. Previously the selection remained visible during submission, which could suppress the banner under Windows fullscreen notification rules. ## Changes
- Default to capture → notification → click → editor. **Open editor after screen capture** is now off by default; explicitly saved preferences are preserved. - Show automatic update announcements using the same native Windows notification channel as screenshots. Review update opens the update check and confirmation; Later dismisses the notification.
- Retain the editor fallback for immediate notification errors. - Retry notification delivery after five minutes if Windows rejects it, and log update checks and delivery results. Successfully submitted notifications are deduplicated for the current version during the running session.
- Check for updates in the background every 6 hours after the startup check. Notify once per version per session, retry after network failures at the next interval, and install only after confirmation. - Fix Settings flicker when using the mouse wheel: skip layout work at scroll boundaries, move only changed controls in one batch, and avoid erasing the entire window background.
Verified: 176 native checks, 403 input/layout checks at 240 DPI, screenshot editor UI integration, ## Validation
isolated notification registration checks, and 45 updater checks. The new UI regression test failed 203 native checks, 456 input/layout checks, screenshot editor UI integration, isolated notification registration and activation checks, and 45 updater checks. Regression coverage includes notification retry/deduplication, review-only activation, partial wheel steps, scroll boundaries and batched control movement.
before the fix and passes afterward: the selection is hidden at notification submission.
Banner behavior still needs confirmation on the affected laptop. Windows Do Not Disturb can
send notifications directly to the notification center; asynchronous toast failures are not yet monitored.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the **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.
v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
For manual installation, exit KSwitcher 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 the resident, Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
configurator and screenshot executables, a release manifest, offline dictionaries, documentation
and license notices; no personal settings or clipboard history. Windows notification banners follow system notification and Do not disturb settings. The new update notification behavior takes effect after installing v0.6.9.
Build and package: Build and package:
```powershell ```powershell
.\build.ps1 -Stage -Test .\build.ps1 -Test
.\package.ps1 -PublishRelease .\package.ps1 -PublishRelease
``` ```
Commit the archive, checksum and sources together. Publish both assets on a stable 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.
`release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
+1 -1
View File
@@ -1 +1 @@
a43786b06878da0f069dbad0fa29587a736dd2d8904c7bc991f5cf0c0570a16f KSwitcher-win-x64.zip cd6b3dd62065e50b68cf77344fbad8d7be41467142f31527933342f00e3c0f63 KSwitcher-win-x64.zip
+4 -4
View File
@@ -27,7 +27,7 @@ try {
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o & windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' } if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' } if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe' $configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
@@ -43,7 +43,7 @@ try {
if ($LASTEXITCODE) { throw 'Test build failed' } if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe & .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' } if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' } if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe & .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' } if ($LASTEXITCODE) { throw 'Input tests failed' }
@@ -63,13 +63,13 @@ try {
if ($LASTEXITCODE) { throw 'Updater tests failed' } if ($LASTEXITCODE) { throw 'Updater tests failed' }
} }
if ($Integration) { if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' } if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe & .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' } if ($LASTEXITCODE) { throw 'Integration tests failed' }
} }
if ($Ocr) { if ($Ocr) {
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe & $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR test build failed' } if ($LASTEXITCODE) { throw 'OCR test build failed' }
& .\build\KSwitcherOcrTests.exe & .\build\KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR tests failed' } if ($LASTEXITCODE) { throw 'OCR tests failed' }
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,3,0 FILEVERSION 0,6,9,0
PRODUCTVERSION 0,6,3,0 PRODUCTVERSION 0,6,9,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.3" VALUE "FileVersion", "0.6.9"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+105 -7
View File
@@ -233,6 +233,7 @@ class Transaction
} // namespace } // namespace
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test) App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
{ {
updateNotifier = notifyUpdateAvailable;
current = this; current = this;
data = dataRoot.empty() ? dataDirectory() : dataRoot; data = dataRoot.empty() ? dataDirectory() : dataRoot;
std::filesystem::create_directories(data); std::filesystem::create_directories(data);
@@ -269,8 +270,15 @@ App::~App()
std::lock_guard lock(jobsMutex); std::lock_guard lock(jobsMutex);
stop = true; stop = true;
jobs.clear(); jobs.clear();
pendingHistorySearch = {};
} }
jobsReady.notify_all(); jobsReady.notify_all();
historySearchReady.notify_all();
historyOcrReady.notify_all();
if (historyOcrWorker.joinable())
historyOcrWorker.join();
if (historySearchWorker.joinable())
historySearchWorker.join();
if (worker.joinable()) if (worker.joinable())
worker.join(); worker.join();
RemoveClipboardFormatListener(window); RemoveClipboardFormatListener(window);
@@ -291,6 +299,83 @@ void App::enqueue(std::function<void()> job)
} }
jobsReady.notify_one(); jobsReady.notify_one();
} }
void App::enqueueHistorySearch(std::function<void()> job)
{
{
std::lock_guard lock(jobsMutex);
if (stop) return;
// Only the latest query matters. Clipboard capture and OCR must not delay it.
pendingHistorySearch = std::move(job);
if (!historySearchWorker.joinable())
historySearchWorker = std::thread([this]
{
for (;;)
{
std::function<void()> searchJob;
{
std::unique_lock lock(jobsMutex);
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
if (stop) return;
searchJob = std::move(pendingHistorySearch);
pendingHistorySearch = {};
}
try { searchJob(); }
catch (const std::exception &e) { report(wide(e.what())); }
}
});
}
historySearchReady.notify_one();
}
void App::scheduleHistoryOcr(const Settings &config)
{
{
std::lock_guard lock(jobsMutex);
if (stop || !history || !config.history) return;
historyOcrSettings = config;
historyOcrRequested = true;
if (!historyOcrWorker.joinable())
historyOcrWorker = std::thread([this]
{
for (;;)
{
Settings config;
{
std::unique_lock lock(jobsMutex);
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
if (stop) return;
historyOcrRequested = false;
config = historyOcrSettings;
}
for (const auto &entry : history->entries())
{
{ std::lock_guard lock(jobsMutex); if (stop) return; }
auto profile = ocrProfile(config);
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
try
{
auto text = recognizeText(history->load(entry.id), config);
std::lock_guard lock(jobsMutex);
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
if (history->saveOcr(entry.id, text, config))
PostMessageW(window, HistoryMessage, 0, 0);
}
catch (const std::exception &e)
{
// Automatic OCR never changes the clipboard or opens error dialogs.
log(L"History OCR: " + wide(e.what()));
try {
std::lock_guard lock(jobsMutex);
if (!stop && ocrProfile(historyOcrSettings) == profile)
history->saveOcr(entry.id, std::nullopt, config);
}
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
}
}
}
});
}
historyOcrReady.notify_one();
}
void App::report(const std::wstring &text) void App::report(const std::wstring &text)
{ {
log(text); log(text);
@@ -333,6 +418,7 @@ void App::applySettings(const Settings &s)
{ {
bool autostartChanged = false; bool autostartChanged = false;
validateSettings(s); validateSettings(s);
validateTesseract(s);
if (s.autoCorrect) if (s.autoCorrect)
{ {
AutoCorrector probe; AutoCorrector probe;
@@ -375,6 +461,7 @@ void App::applySettings(const Settings &s)
} }
// Clear on the worker so the next check reloads edits to dictionary files. // Clear on the worker so the next check reloads edits to dictionary files.
enqueue([this] { autoCorrector.configure({}, {}); }); enqueue([this] { autoCorrector.configure({}, {}); });
scheduleHistoryOcr(s);
SetThreadExecutionState(ES_CONTINUOUS | SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0)); (settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
} }
@@ -588,6 +675,7 @@ void App::dispatch(int command)
return; return;
try try
{ {
if (command == 23) { checkUpdate(true); return; }
if (command == Screenshot) if (command == Screenshot)
{ {
if (!busy) { resetInput(); launchScreenshot(*this); } if (!busy) { resetInput(); launchScreenshot(*this); }
@@ -1051,6 +1139,7 @@ void App::captureHistory()
!f.data[1] && !f.data[2] && !f.data[3]) !f.data[1] && !f.data[2] && !f.data[3])
return; return;
history->add(clip, app, config); history->add(clip, app, config);
scheduleHistoryOcr(config);
PostMessageW(window, HistoryMessage, 0, 0); PostMessageW(window, HistoryMessage, 0, 0);
} }
catch (const std::exception &e) catch (const std::exception &e)
@@ -1078,7 +1167,9 @@ void App::recognizeEntry(std::wstring id)
} reset{busy}; } reset{busy};
try try
{ {
auto text = recognizeText(history->load(id)); auto cached = history->ocrText(id, ocrProfile(config));
auto text = cached ? *cached : recognizeText(history->load(id), config);
if (!cached) history->saveOcr(id, text, config);
if (text.empty()) if (text.empty())
{ {
report(L"No text was found in the image."); report(L"No text was found in the image.");
@@ -1239,6 +1330,8 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (app->updateWorker.joinable()) app->updateWorker.join(); if (app->updateWorker.joinable()) app->updateWorker.join();
app->updateRunning = false; app->updateRunning = false;
app->installingUpdate = false; app->installingUpdate = false;
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
(result->error.empty() ? L"" : L"; " + result->error));
if (result->state == L"prepared") if (result->state == L"prepared")
{ {
// The helper has verified and staged the package; it waits for process exit. // The helper has verified and staged the package; it waits for process exit.
@@ -1255,14 +1348,17 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->checkUpdate(true, true, result->version); app->checkUpdate(true, true, result->version);
} }
else if (app->notifiedUpdate != result->version) else if (app->notifiedUpdate != result->version)
{
if (app->updateNotifier(result->version))
{ {
app->notifiedUpdate = result->version; app->notifiedUpdate = result->version;
NOTIFYICONDATAW icon{sizeof(icon)}; log(L"Update notification submitted to Windows: " + result->version);
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO; }
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available"); else
auto text = result->version + L" is available. Select Update in the tray menu to install."; {
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE); log(L"Update notification failed; retrying in 5 minutes: " + result->version);
Shell_NotifyIconW(NIM_MODIFY, &icon); SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
}
} }
} }
else if (result->state == L"current") else if (result->state == L"current")
@@ -1442,6 +1538,8 @@ int App::execute()
if (!keyboardHook || !mouseHook) if (!keyboardHook || !mouseHook)
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable."); report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
AddClipboardFormatListener(window); AddClipboardFormatListener(window);
// Resume pending OCR, including older images without a saved OCR state.
scheduleHistoryOcr(settings);
addTray(); addTray();
SetTimer(window, 1, 60000, nullptr); SetTimer(window, 1, 60000, nullptr);
SetThreadExecutionState(ES_CONTINUOUS | SetThreadExecutionState(ES_CONTINUOUS |
+10
View File
@@ -61,6 +61,7 @@ class App
void recognizeEntry(std::wstring); void recognizeEntry(std::wstring);
void captureHistory(); void captureHistory();
void enqueue(std::function<void()>); void enqueue(std::function<void()>);
void enqueueHistorySearch(std::function<void()>);
void report(const std::wstring &); void report(const std::wstring &);
void trayMenu(); void trayMenu();
void addTray(); void addTray();
@@ -83,9 +84,18 @@ class App
std::condition_variable jobsReady; std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs; std::deque<std::function<void()>> jobs;
std::thread worker; std::thread worker;
std::thread historySearchWorker;
std::condition_variable historySearchReady;
std::function<void()> pendingHistorySearch;
std::thread historyOcrWorker;
std::condition_variable historyOcrReady;
bool historyOcrRequested{};
Settings historyOcrSettings;
void scheduleHistoryOcr(const Settings &);
std::thread updateWorker; std::thread updateWorker;
bool updateRunning{}, installingUpdate{}; bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate, notifiedUpdate; std::wstring availableUpdate, notifiedUpdate;
bool (*updateNotifier)(const std::wstring &){};
bool stop{}; bool stop{};
UINT taskbarCreated{}; UINT taskbarCreated{};
uint64_t pendingAutoVersion{}; uint64_t pendingAutoVersion{};
+3
View File
@@ -49,6 +49,9 @@ struct Settings
bool autoCorrect = false; bool autoCorrect = false;
bool writeLog = true; bool writeLog = true;
bool screenshotAutoOpenEditor = false; bool screenshotAutoOpenEditor = false;
bool useTesseract = false;
std::wstring tesseractPath;
std::wstring tesseractLanguages = L"ukr+eng+rus";
int autoMaxRank = 0; int autoMaxRank = 0;
int autoMinLength = 3; int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\""; std::wstring autoPrefixChars = L"([{«“‘\"";
+3
View File
@@ -45,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
cmd = 21; cmd = 21;
else if (arg == L"--exit") else if (arg == L"--exit")
cmd = 99; cmd = 99;
else if (arg == L"--check-update")
cmd = 23;
LocalFree(argv); LocalFree(argv);
if (existing) if (existing)
{ {
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher"); HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (window) if (window)
{ {
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
COPYDATASTRUCT data{1, sizeof(cmd), &cmd}; COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
DWORD_PTR result{}; DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result); SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
+115
View File
@@ -271,4 +271,119 @@ std::wstring recognizeText(const Clip &clip)
WindowsDeleteString(text); WindowsDeleteString(text);
return out; return out;
} }
std::filesystem::path tesseractExecutable(const Settings &s)
{
if (!s.tesseractPath.empty())
{
std::filesystem::path path(s.tesseractPath);
if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe");
return path;
}
for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"})
{
wchar_t base[32768]{};
auto length = GetEnvironmentVariableW(variable, base, std::size(base));
if (!length || length >= std::size(base)) continue;
auto root = std::filesystem::path(base);
for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"})
if (std::filesystem::is_regular_file(root / relative)) return root / relative;
}
throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options.");
}
std::wstring ocrProfile(const Settings &s)
{
return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows";
}
namespace
{
struct TessLibrary
{
HMODULE module{};
explicit TessLibrary(const Settings &s)
{
auto exe = tesseractExecutable(s);
if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist");
auto library = exe.parent_path() / L"libtesseract-5.dll";
module = LoadLibraryExW(library.c_str(), nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies.");
}
~TessLibrary() { if (module) FreeLibrary(module); }
template<class T> T symbol(const char *name)
{
auto address = GetProcAddress(module, name);
if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name);
T result; static_assert(sizeof(result) == sizeof(address));
memcpy(&result, &address, sizeof(result));
return result;
}
};
struct TessEngine
{
TessLibrary library;
void *handle{};
void (*destroy)(void *){};
explicit TessEngine(const Settings &s) : library(s)
{
auto folder = tesseractExecutable(s).parent_path() / L"tessdata";
size_t start = 0;
do
{
auto end = s.tesseractLanguages.find(L'+', start);
auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start);
if (!std::filesystem::is_regular_file(folder / (language + L".traineddata")))
throw std::runtime_error("Tesseract language is not installed: " + utf8(language));
if (end == std::wstring::npos) break;
start = end + 1;
} while (true);
auto create = library.symbol<void *(*)()>("TessBaseAPICreate");
destroy = library.symbol<void (*)(void *)>("TessBaseAPIDelete");
auto init = library.symbol<int (*)(void *, const char *, const char *)>("TessBaseAPIInit3");
handle = create();
if (!handle) throw std::runtime_error("Cannot create Tesseract engine");
if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0)
{
destroy(handle); handle = nullptr;
throw std::runtime_error("Cannot initialize the selected Tesseract languages");
}
}
~TessEngine() { if (handle) destroy(handle); }
};
}
void validateTesseract(const Settings &s)
{
if (s.useTesseract) { TessEngine engine(s); }
}
std::wstring recognizeText(const Clip &clip, const Settings &s)
{
if (!s.useTesseract) return recognizeText(clip);
auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult);
struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)};
// Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs.
TessEngine engine(s);
auto &lib = engine.library;
auto image = lib.symbol<void (*)(void *, const unsigned char *, int, int, int, int)>("TessBaseAPISetImage");
auto mode = lib.symbol<void (*)(void *, int)>("TessBaseAPISetPageSegMode");
auto recognize = lib.symbol<int (*)(void *, void *)>("TessBaseAPIRecognize");
auto getText = lib.symbol<char *(*)(void *)>("TessBaseAPIGetUTF8Text");
auto freeText = lib.symbol<void (*)(const char *)>("TessDeleteText");
auto createMonitor = lib.symbol<void *(*)()>("TessMonitorCreate");
auto freeMonitor = lib.symbol<void (*)(void *)>("TessMonitorDelete");
auto deadline = lib.symbol<void (*)(void *, int)>("TessMonitorSetDeadlineMSecs");
std::unique_ptr<void, void (*)(void *)> monitor(createMonitor(), freeMonitor);
if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor");
auto pixels = decodeImage(clip);
Bytes gray(size_t(pixels.width) * pixels.height);
for (size_t i = 0; i < gray.size(); ++i)
gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8);
image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width);
mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs.
deadline(monitor.get(), 30000);
if (recognize(engine.handle, monitor.get()) != 0)
throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds");
std::unique_ptr<char, void (*)(const char *)> text(getText(engine.handle), freeText);
if (!text) throw std::runtime_error("Tesseract returned no result");
return wide(text.get());
}
} // namespace ks } // namespace ks
+4
View File
@@ -10,4 +10,8 @@ struct Pixels
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0); Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &); Format imageDib(const Pixels &);
std::wstring recognizeText(const Clip &); std::wstring recognizeText(const Clip &);
std::wstring recognizeText(const Clip &, const Settings &);
std::filesystem::path tesseractExecutable(const Settings &);
void validateTesseract(const Settings &);
std::wstring ocrProfile(const Settings &);
} // namespace ks } // namespace ks
+221 -40
View File
@@ -3,6 +3,8 @@
#include "../resources/resource.h" #include "../resources/resource.h"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <functional>
#include <unordered_set>
#include <windowsx.h> #include <windowsx.h>
#include <commctrl.h> #include <commctrl.h>
#include <commdlg.h> #include <commdlg.h>
@@ -51,7 +53,7 @@ bool confirmScreenshotClose(HWND owner)
// If Windows cannot display the confirmation, keep the screenshot open. // If Windows cannot display the confirmation, keep the screenshot open.
return SUCCEEDED(result) && button==IDYES; return SUCCEEDED(result) && button==IDYES;
} }
constexpr int ToolIds[]{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 struct Surface
{ {
HDC dc = CreateCompatibleDC(nullptr); HDC dc = CreateCompatibleDC(nullptr);
@@ -121,6 +123,44 @@ bool chooseTextFont(HWND owner,Mark &m)
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0; m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
return true; return true;
} }
constexpr int CursorTools[]{10,11,22,23,24,25,19};
HCURSOR makeToolCursor(int tool,int size)
{
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
auto bitmap=CreateBitmap(size,size*2,1,1,nullptr);
auto dc=CreateCompatibleDC(nullptr);
if(!bitmap || !dc) { if(bitmap) DeleteObject(bitmap); if(dc) DeleteDC(dc); return nullptr; }
auto oldBitmap=SelectObject(dc,bitmap);
PatBlt(dc,0,0,size,size,WHITENESS); PatBlt(dc,0,size,size,size,BLACKNESS);
auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
auto draw=[&](int offset,int width,COLORREF color) {
auto pen=CreatePen(PS_SOLID,width,color); auto oldPen=SelectObject(dc,pen);
auto x=[&](int v){return MulDiv(v,size,32);};
auto y=[&](int v){return offset+MulDiv(v,size,32);};
auto line=[&](int a,int b,int c,int d){MoveToEx(dc,x(a),y(b),nullptr); LineTo(dc,x(c),y(d));};
line(1,7,13,7); line(7,1,7,13); // The cross center is the exact drawing point.
switch(tool) {
case 10:
Ellipse(dc,x(17),y(17),x(30),y(30));
line(22,21,24,20); line(24,20,24,27); line(21,27,27,27); break;
case 11: line(18,29,29,18); line(22,18,29,18); line(29,18,29,25); break;
case 22: line(18,29,29,18); break;
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
case 19:
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
}
SelectObject(dc,oldPen); DeleteObject(pen);
};
int outline=std::max(3,MulDiv(4,size,32)),stroke=std::max(1,MulDiv(2,size,32));
draw(0,outline,RGB(0,0,0));
draw(size,outline,RGB(255,255,255)); draw(size,stroke,RGB(0,0,0));
SelectObject(dc,oldBrush); SelectObject(dc,oldBitmap); DeleteDC(dc);
ICONINFO info{}; info.fIcon=FALSE; info.xHotspot=info.yHotspot=MulDiv(7,size,32); info.hbmMask=bitmap;
auto cursor=(HCURSOR)CreateIconIndirect(&info); DeleteObject(bitmap); return cursor;
}
struct Capture struct Capture
{ {
ScreenshotHost &app; ScreenshotHost &app;
@@ -129,7 +169,7 @@ struct Capture
bool editing{}, dragging{}, arrow{}, owned{}, copied{}; bool editing{}, dragging{}, arrow{}, owned{}, copied{};
bool autoOpenEditor; bool autoOpenEditor;
bool highlight{}; bool highlight{};
bool freehand=true; bool pointer=false, freehand=true;
bool straight{}, textTool{}; bool straight{}, textTool{};
Mark textDefaults{}; Mark textDefaults{};
ScreenshotShape shape=ScreenshotShape::None; ScreenshotShape shape=ScreenshotShape::None;
@@ -157,6 +197,7 @@ struct Capture
HICON toolIcons[3][15]{}; HICON toolIcons[3][15]{};
int iconSize{}; int iconSize{};
HCURSOR pencilCursor{}; HCURSOR pencilCursor{};
HCURSOR drawingCursors[std::size(CursorTools)]{};
int pencilCursorSize{}; int pencilCursorSize{};
int unit = 1, panelHeight = 1, statusHeight = 0; int unit = 1, panelHeight = 1, statusHeight = 0;
int hoveredTool=0; int hoveredTool=0;
@@ -165,6 +206,7 @@ struct Capture
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {} explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
~Capture() { ~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor); if(pencilCursor) DestroyCursor(pencilCursor);
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
if (font) DeleteObject(font); if (font) DeleteObject(font);
if(inlineFont) DeleteObject(inlineFont); if(inlineFont) DeleteObject(inlineFont);
for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon); for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon);
@@ -197,7 +239,7 @@ void updateStatus(Capture &c)
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps"; else if(part==MarkPart::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::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter"; else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
else if(part==MarkPart::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) { else if(c.selected>=0) {
const auto &m=c.marks[c.selected]; const auto &m=c.marks[c.selected];
if(c.selectedLabel || part==MarkPart::Label) if(c.selectedLabel || part==MarkPart::Label)
@@ -205,11 +247,12 @@ void updateStatus(Capture &c)
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees"; else 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 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.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.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"; 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 = draw over an object | F2 on a line = caption"; 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 | Drag objects to move | Ctrl+C = copy screenshot"; else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
if(text!=c.statusText) { if(text!=c.statusText) {
c.statusText=std::move(text); c.statusText=std::move(text);
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str()); SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
@@ -250,6 +293,15 @@ void positionInlineText(Capture &c)
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y)); int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE); SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
} }
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) void finishInlineText(HWND w,Capture &c,bool commit)
{ {
if(!c.inlineText) return; if(!c.inlineText) return;
@@ -272,8 +324,9 @@ void finishInlineText(HWND w,Capture &c,bool commit)
} else if(!empty) { } else if(!empty) {
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false; 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; c.textIndex=-1; c.editingLabel=false;
InvalidateRect(w,nullptr,FALSE); InvalidateRect(w,nullptr,FALSE);
} }
@@ -510,6 +563,11 @@ void layout(HWND w, Capture &c)
int dpi = GetDpiForWindow(w); int dpi = GetDpiForWindow(w);
int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi); int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi);
if(cursorSize!=c.pencilCursorSize) { if(cursorSize!=c.pencilCursorSize) {
for(size_t i=0;i<std::size(CursorTools);++i) {
auto cursor=makeToolCursor(CursorTools[i],cursorSize);
if(c.drawingCursors[i]) DestroyCursor(c.drawingCursors[i]);
c.drawingCursors[i]=cursor;
}
auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0); auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0);
if(cursor) { if(cursor) {
if(c.pencilCursor) DestroyCursor(c.pencilCursor); if(c.pencilCursor) DestroyCursor(c.pencilCursor);
@@ -536,7 +594,7 @@ void layout(HWND w, Capture &c)
RECT statusRect{}; GetWindowRect(status,&statusRect); RECT statusRect{}; GetWindowRect(status,&statusRect);
c.statusHeight=statusRect.bottom-statusRect.top; c.statusHeight=statusRect.bottom-statusRect.top;
r.bottom-=c.statusHeight; r.bottom-=c.statusHeight;
const int positions[]{8,44,80,116,152,188,224,260,296,332,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)); static_assert(std::size(positions)==std::size(ToolIds));
for (size_t i = 0; i < std::size(ToolIds); ++i) for (size_t i = 0; i < std::size(ToolIds); ++i)
{ {
@@ -575,6 +633,15 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
{ {
if((c.arrow || c.highlight || c.freehand || c.textTool) && (GetKeyState(VK_CONTROL)&0x8000)) return {-1,MarkPart::None}; 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); 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) if(c.selected>=0)
{ {
const auto &m=c.marks[c.selected]; const auto &m=c.marks[c.selected];
@@ -596,20 +663,24 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
} }
return {-1,MarkPart::None}; return {-1,MarkPart::None};
} }
void captureCursor(HWND w, Capture &c) HCURSOR toolCursor(const Capture &c)
{ {
LPCWSTR cursor=IDC_CROSS; if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
if(c.editing) if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
if(c.freehand && c.pencilCursor) return c.pencilCursor;
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
for(size_t i=0;i<std::size(CursorTools);++i)
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
return LoadCursorW(nullptr,IDC_CROSS);
}
HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
{ {
POINT p; GetCursorPos(&p); ScreenToClient(w,&p); if(!c.editing) return LoadCursorW(nullptr,IDC_CROSS);
auto hit=c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false)); LPCWSTR cursor=nullptr;
if(!c.dragging && !PtInRect(&c.view,p) && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
if(!c.dragging && !PtInRect(&c.view,p)) cursor=IDC_ARROW;
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL; if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND; else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE; else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
{
const auto &mark=c.marks[hit.first]; const auto &mark=c.marks[hit.first];
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to}; auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x)); double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
@@ -618,8 +689,15 @@ void captureCursor(HWND w, Capture &c)
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW}; const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis]; cursor=cursors[axis];
} }
return cursor?LoadCursorW(nullptr,cursor):inside?toolCursor(c):LoadCursorW(nullptr,IDC_ARROW);
} }
SetCursor(c.editing && c.freehand && cursor==IDC_CROSS && c.pencilCursor?c.pencilCursor:LoadCursorW(nullptr,cursor)); void captureCursor(HWND w, Capture &c)
{
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
bool inside=PtInRect(&c.view,p)!=FALSE;
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
SetCursor(actionCursor(c,hit,inside || c.dragging));
if(c.editing) { if(c.editing) {
POINT p; GetCursorPos(&p); ScreenToClient(w,&p); POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
c.hoveredPart=markAt(c,imagePoint(c,p,false)).second; c.hoveredPart=markAt(c,imagePoint(c,p,false)).second;
@@ -686,7 +764,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
{ {
auto dc=item.hDC; auto dc=item.hDC;
RECT r=item.rcItem; 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==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool); (item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
bool disabled=item.itemState&ODS_DISABLED; bool disabled=item.itemState&ODS_DISABLED;
@@ -702,7 +780,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner); RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush); SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10; int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
auto icon=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(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
if(item.CtlID==20) { if(item.CtlID==20) {
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor; auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
@@ -763,7 +841,7 @@ void edit(HWND w, Capture &c)
InitCommonControlsEx(&statusControls); InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS, CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr); 0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
const wchar_t *labels[]{L"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)"}; L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
static_assert(std::size(labels)==std::size(ToolIds)); static_assert(std::size(labels)==std::size(ToolIds));
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX, auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
@@ -855,7 +933,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} }
break; break;
case WM_DRAWITEM: case WM_DRAWITEM:
if((wp>=10 && wp<=13) || (wp>=18 && wp<=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; break;
case WM_CTLCOLORSTATIC: case WM_CTLCOLORSTATIC:
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85)); SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
@@ -882,7 +960,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
auto draft=c->marks[c->selected]; auto draft=c->marks[c->selected];
if(chooseTextFont(w,draft)) { if(chooseTextFont(w,draft)) {
c->remember(c->marks); c->marks[c->selected]=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); } else chooseTextFont(w,c->textDefaults);
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0; SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
@@ -936,7 +1015,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
auto &mark=c->marks[c->selected]; auto &mark=c->marks[c->selected];
int &value=mark.isText()?mark.fontSize:mark.thickness; 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); 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)==13) { copy(w,*c); return 0; }
if (LOWORD(wp)==12 || LOWORD(wp)==18) if (LOWORD(wp)==12 || LOWORD(wp)==18)
@@ -945,8 +1024,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; } if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
c->syncHistory(); c->syncHistory();
} }
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=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->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
c->straight=LOWORD(wp)==22; c->straight=LOWORD(wp)==22;
c->textTool=LOWORD(wp)==26; c->textTool=LOWORD(wp)==26;
@@ -960,10 +1040,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE); InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE); InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE); InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
for(int id=22;id<=26;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE); for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
} }
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0; SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
case WM_KEYDOWN: case WM_KEYDOWN:
if (wp==VK_ESCAPE) { if (wp==VK_ESCAPE) {
if(!c->editing) DestroyWindow(w); // Cancel region selection only. if(!c->editing) DestroyWindow(w); // Cancel region selection only.
@@ -996,6 +1076,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(w,nullptr,FALSE); InvalidateRect(w,nullptr,FALSE);
} }
if(!(GetKeyState(VK_CONTROL)&0x8000)) { 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=='T') SendMessageW(w,WM_COMMAND,26,0);
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0); if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0); if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
@@ -1055,6 +1136,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(w,nullptr,FALSE); return 0; InvalidateRect(w,nullptr,FALSE); return 0;
} }
c->selected=-1; c->selectedLabel=false; c->selected=-1; c->selectedLabel=false;
if(c->pointer) { InvalidateRect(w,nullptr,FALSE); updateStatus(*c); return 0; }
} }
if(c->editing && c->textTool) { if(c->editing && c->textTool) {
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor; Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
@@ -1126,7 +1208,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (!c->editing) { finishSelection(w,*c); return 0; } if (!c->editing) { finishSelection(w,*c); return 0; }
else if(c->part!=MarkPart::None) 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]; auto &mark=c->marks[c->selected];
linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6); linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6);
@@ -1353,27 +1436,107 @@ ScreenshotMark cloneScreenshotMark(const ScreenshotMark &original,const std::vec
{ {
auto result=original; auto result=original;
result.fromNumber=result.toNumber=0; result.fromNumber=result.toNumber=0;
result.id=0; result.fromLink={}; result.toLink={};
if(result.number) result.number=nextScreenshotNumber(marks); if(result.number) result.number=nextScreenshotNumber(marks);
auto bounds=markBounds(result); auto bounds=markBounds(result);
LONG dx=bounds.right+16<width?16:-16; LONG dx=bounds.right+16<width?16:-16;
LONG dy=bounds.bottom+16<height?16:-16; LONG dy=bounds.bottom+16<height?16:-16;
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height); return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
} }
namespace {
void assignMarkIds(std::vector<ScreenshotMark> &marks)
{
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, void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
const std::vector<ScreenshotMark> &marks, double tolerance) std::vector<ScreenshotMark> &marks, double tolerance)
{ {
auto &link=start?arrow.fromNumber:arrow.toNumber; assignMarkIds(marks);
link=0; auto &number=start?arrow.fromNumber:arrow.toNumber;
if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return; 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) 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) if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) {
{ number=it->number; return;
link=it->number; }
break; 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) void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
{ {
assignMarkIds(marks);
auto badge=[&](int number) -> const ScreenshotMark * { auto badge=[&](int number) -> const ScreenshotMark * {
if(number) for(const auto &m:marks) if(m.number==number) return &m; if(number) for(const auto &m:marks) if(m.number==number) return &m;
return nullptr; return nullptr;
@@ -1383,9 +1546,21 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
if(length<0.001) { dx=start?1:-1; dy=0; length=1; } 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))}; 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) std::vector<int> state(marks.size());
{ std::function<void(size_t)> resolve=[&](size_t index) {
if(m.number || m.isHighlight() || m.isText() || m.shape!=ScreenshotShape::None) continue; 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); auto from=badge(m.fromNumber),to=badge(m.toNumber);
if(!from) m.fromNumber=0; if(!from) m.fromNumber=0;
if(!to) m.toNumber=0; if(!to) m.toNumber=0;
@@ -1394,6 +1569,9 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
if(to) m.to=rim(*to,start,false); if(to) m.to=rim(*to,start,false);
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; } 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) MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
{ {
@@ -1461,6 +1639,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
POINT position, int width, int height, bool snapRotation) POINT position, int width, int height, bool snapRotation)
{ {
auto m=original; auto m=original;
if(position.x==origin.x && position.y==origin.y) return m;
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) { if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
if(position.x==origin.x && position.y==origin.y) return m; if(position.x==origin.x && position.y==origin.y) return m;
auto text=m.isText()?m:screenshotArrowLabel(m); auto text=m.isText()?m:screenshotArrowLabel(m);
@@ -1496,6 +1675,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
else if(part==MarkPart::Body) else if(part==MarkPart::Body)
{ {
m.fromNumber=m.toNumber=0; m.fromNumber=m.toNumber=0;
m.fromLink={}; m.toLink={};
auto shift=[&](LONG delta,LONG first,LONG last,int extent) { auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
LONG low=-first,high=LONG(extent-1)-last; LONG low=-first,high=LONG(extent-1)-last;
if(low>high && m.isText()) { if(low>high && m.isText()) {
@@ -1515,6 +1695,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
else if(part==MarkPart::Start || part==MarkPart::End) else if(part==MarkPart::Start || part==MarkPart::End)
{ {
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0; (part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
(part==MarkPart::Start?m.fromLink:m.toLink)={};
auto &endpoint=part==MarkPart::Start?m.from:m.to; auto &endpoint=part==MarkPart::Start?m.from:m.to;
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1)); endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1)); endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
+7 -1
View File
@@ -17,6 +17,10 @@ enum class LineStyle { Solid, Dotted, Dashed, DashDot };
enum class LineEnds { End, Start, Both, None }; enum class LineEnds { End, Start, Both, None };
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse }; enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
struct ScreenshotOffset { double x, y; }; 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 { struct ScreenshotMark {
POINT from, to; int number; int radius = 15; int thickness = 3; POINT from, to; int number; int radius = 15; int thickness = 3;
int fromNumber = 0, toNumber = 0; int fromNumber = 0, toNumber = 0;
@@ -33,6 +37,8 @@ struct ScreenshotMark {
std::wstring label; std::wstring label;
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels. ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
bool labelRotationManual=false; bool labelRotationManual=false;
unsigned long long id{};
ScreenshotLink fromLink, toLink;
bool isText() const { return !text.empty(); } bool isText() const { return !text.empty(); }
bool isHighlight() const { return !points.empty() && !freehand; } bool isHighlight() const { return !points.empty() && !freehand; }
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; } bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
@@ -48,7 +54,7 @@ class ScreenshotHistory {
}; };
void resolveScreenshotLinks(std::vector<ScreenshotMark> &); void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop, void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
const std::vector<ScreenshotMark> &, double tolerance); std::vector<ScreenshotMark> &, double tolerance);
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label }; enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16); POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height); ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
+6 -2
View File
@@ -83,8 +83,12 @@ public:
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; } ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; } ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override { HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG; if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\""; const bool update=std::wstring(args)==L"check-update";
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
auto command=update ? L"\""+path+L"\" --check-update" :
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{}; 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 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()); auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
+39
View File
@@ -181,5 +181,44 @@ bool notifyScreenshot(const Clip &clip)
} }
std::string screenshotNotificationError() { return notificationError; } std::string screenshotNotificationError() { return notificationError; }
std::wstring updateToastXml(const std::wstring &version)
{
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
L" is available. Click to review and install.</text></binding></visual>"
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
}
bool notifyUpdateAvailable(const std::wstring &version)
{
try {
// Reuse the screenshot notification identity, Windows settings and COM activator.
registerScreenshotNotifications();
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
check(manager->CreateToastNotifierWithId(appId.value,&notifier));
Notifications::NotificationSetting setting{};
auto settingsResult=notifier->get_Setting(&setting);
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
if(setting!=Notifications::NotificationSetting_Enabled)
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
check(RoActivateInstance(documentClass.value,&instance));
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
String content(updateToastXml(version)); check(io->LoadXml(content.value));
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
// Repeated submissions replace the existing update card in Notification Center.
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
check(notifier->Show(toast.Get()));
return true;
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
}
} }
+76 -3
View File
@@ -44,6 +44,13 @@ bool validId(const std::wstring &id)
} // namespace } // namespace
void validateSettings(const Settings &s) void validateSettings(const Settings &s)
{ {
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
throw std::runtime_error("Invalid Tesseract executable path");
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
std::vector<Hotkey> used; std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot}; std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
keys.insert(keys.end(), s.direct.begin(), s.direct.end()); keys.insert(keys.end(), s.direct.begin(), s.direct.end());
@@ -125,6 +132,9 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.switchLayout = j.value("switchSystemLayout", true); s.switchLayout = j.value("switchSystemLayout", true);
s.writeLog = j.value("writeLog", true); s.writeLog = j.value("writeLog", true);
s.history = j.value("historyEnabled", true); s.history = j.value("historyEnabled", true);
s.useTesseract = j.value("useTesseract", false);
s.tesseractPath = str(j, "tesseractPath");
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
s.autostart = j.value("startWithSystem", false); s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false); s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false); s.awake = j.value("preventScreenLock", false);
@@ -196,6 +206,9 @@ Bytes encodeSettings(const Settings &s)
{"writeLog", s.writeLog}, {"writeLog", s.writeLog},
{"autostartAsAdministrator", s.elevatedAutostart}, {"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history}, {"historyEnabled", s.history},
{"useTesseract", s.useTesseract},
{"tesseractPath", utf8(s.tesseractPath)},
{"tesseractLanguages", utf8(s.tesseractLanguages)},
{"startWithSystem", s.autostart}, {"startWithSystem", s.autostart},
{"preventScreenLock", s.awake}, {"preventScreenLock", s.awake},
{"maxItems", s.maxItems}, {"maxItems", s.maxItems},
@@ -297,7 +310,8 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
e.value("at", uint64_t{}), e.value("at", uint64_t{}),
e.value("pinned", false), e.value("pinned", false),
e.value("image", false), e.value("image", false),
e.value("files", false)}; e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat"))) if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
entries_.push_back(std::move(item)); entries_.push_back(std::move(item));
} }
@@ -313,7 +327,7 @@ void History::saveIndex()
{"at", e.at}, {"at", e.at},
{"pinned", e.pinned}, {"pinned", e.pinned},
{"image", e.image}, {"image", e.image},
{"files", e.files}}); {"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j))); atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
} }
std::vector<Entry> History::entries() const std::vector<Entry> History::entries() const
@@ -342,6 +356,7 @@ void History::prune(const Settings &s)
total -= entries_[i].bytes; total -= entries_[i].bytes;
std::error_code ec; std::error_code ec;
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec); std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
entries_.erase(entries_.begin() + i); entries_.erase(entries_.begin() + i);
} }
} }
@@ -429,7 +444,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
GetSystemTimeAsFileTime(&now); GetSystemTimeAsFileTime(&now);
entries_.insert(entries_.begin(), entries_.insert(entries_.begin(),
Entry{id, text, app, encrypted.size(), Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files}); (uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
image ? 1 : 0, ocrProfile(s)});
prune(s); prune(s);
saveIndex(); saveIndex();
} }
@@ -445,6 +461,7 @@ void History::remove(const std::wstring &id)
saveIndex(); saveIndex();
std::error_code ec; std::error_code ec;
std::filesystem::remove(folder_ / (id + L".dat"), ec); std::filesystem::remove(folder_ / (id + L".dat"), ec);
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
} }
void History::pin(const std::wstring &id) void History::pin(const std::wstring &id)
{ {
@@ -464,8 +481,49 @@ void History::clear()
{ {
std::error_code ec; std::error_code ec;
std::filesystem::remove(folder_ / (e.id + L".dat"), ec); std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
} }
} }
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
// A late OCR completion must never recreate a deleted/pruned image.
auto profile = ocrProfile(settings);
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
if (text)
{
auto utf = utf8(*text);
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
// A version byte also makes empty recognition results encryptable by DPAPI.
Bytes payload{1};
payload.insert(payload.end(), utf.begin(), utf.end());
auto encrypted = protect(payload);
auto path = folder_ / (id + L".ocr");
std::error_code ec;
auto previousBytes = std::filesystem::file_size(path, ec);
if (ec) previousBytes = 0;
atomicWrite(path, encrypted);
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
it->ocrState = 2;
}
else it->ocrState = 3;
it->ocrProfile = profile;
prune(settings);
saveIndex();
return true;
}
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
{
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
}
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
return wide(std::string(bytes.begin() + 1, bytes.end()));
}
std::vector<Entry> History::search(const std::wstring &query) const std::vector<Entry> History::search(const std::wstring &query) const
{ {
auto items = entries(); auto items = entries();
@@ -477,6 +535,21 @@ std::vector<Entry> History::search(const std::wstring &query) const
{ {
if (lower(e.preview + L" " + e.app).find(q) != q.npos) if (lower(e.preview + L" " + e.app).find(q) != q.npos)
return false; return false;
if (e.ocrState == 2)
{
try
{
auto text = ocrText(e.id);
if (text && lower(*text).find(q) != q.npos) return false;
}
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
}
// Short previews contain the complete text (or a placeholder for
// textless clips). Do not decrypt image payloads just to rule them out.
// Newlines/tabs are flattened in previews, so those queries still
// need the original text. Exactly 240 characters may be truncated.
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
return true;
try try
{ {
return lower(load(e.id).text()).find(q) == q.npos; return lower(load(e.id).text()).find(q) == q.npos;
+4
View File
@@ -13,6 +13,8 @@ struct Entry
std::wstring id, preview, app; std::wstring id, preview, app;
uint64_t bytes{}, at{}; uint64_t bytes{}, at{};
bool pinned{}, image{}, files{}; bool pinned{}, image{}, files{};
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
std::wstring ocrProfile = L"windows";
}; };
class History class History
{ {
@@ -27,6 +29,8 @@ class History
std::vector<Entry> entries() const; std::vector<Entry> entries() const;
Clip load(const std::wstring &id) const; Clip load(const std::wstring &id) const;
void add(const Clip &, const std::wstring &app, const Settings &); void add(const Clip &, const std::wstring &app, const Settings &);
bool saveOcr(const std::wstring &id, const std::optional<std::wstring> &, const Settings &);
std::optional<std::wstring> ocrText(const std::wstring &id, const std::wstring &profile = L"") const;
void remove(const std::wstring &); void remove(const std::wstring &);
void pin(const std::wstring &); void pin(const std::wstring &);
void clear(); void clear();
+141 -12
View File
@@ -71,17 +71,48 @@ void arrangeWindow(HWND w)
RECT client{}; GetClientRect(w, &client); RECT client{}; GetClientRect(w, &client);
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0; 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; int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
struct Move { HWND child; RECT rect; };
std::vector<Move> moves;
for (const auto &[child, r] : layout->controls) for (const auto &[child, r] : layout->controls)
{ {
int id = GetDlgCtrlID(child); int id = GetDlgCtrlID(child);
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, RECT next{scale(w, r.left) - layout->x,
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0), scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0), next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0), next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
RECT current{}; GetWindowRect(child, &current);
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(&current), 2);
if (!EqualRect(&current, &next)) moves.push_back({child, next});
}
if (!moves.empty())
{
auto batch = BeginDeferWindowPos((int)moves.size());
for (const auto &move : moves)
{
if (!batch) break;
auto r = move.rect;
batch = DeferWindowPos(batch, move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
SWP_NOACTIVATE | SWP_NOZORDER); SWP_NOACTIVATE | SWP_NOZORDER);
} }
if (!batch || !EndDeferWindowPos(batch))
for (const auto &move : moves)
{
auto r = move.rect;
SetWindowPos(move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
}
layout->arranging = false; layout->arranging = false;
InvalidateRect(w, nullptr, TRUE); }
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
{
SCROLLINFO info{sizeof(info), SIF_RANGE | SIF_PAGE};
GetScrollInfo(w, horizontal ? SB_HORZ : SB_VERT, &info);
int next = std::clamp(requested, info.nMin, std::max(info.nMin, info.nMax - std::max(0, (int)info.nPage - 1)));
int &position = horizontal ? layout->x : layout->y;
if (position == next) return;
position = next;
arrangeWindow(w);
} }
void revealControl(HWND child) void revealControl(HWND child)
{ {
@@ -127,7 +158,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
bool vertical = msg == WM_VSCROLL; bool vertical = msg == WM_VSCROLL;
SCROLLINFO info{sizeof(info), SIF_ALL}; SCROLLINFO info{sizeof(info), SIF_ALL};
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info); GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
int &position = vertical ? layout->y : layout->x; int position = vertical ? layout->y : layout->x;
switch (LOWORD(wp)) switch (LOWORD(wp))
{ {
case SB_LINEUP: position -= scale(w, 32); break; case SB_LINEUP: position -= scale(w, 32); break;
@@ -138,7 +169,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
case SB_BOTTOM: position = info.nMax; break; case SB_BOTTOM: position = info.nMax; break;
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break; case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
} }
arrangeWindow(w); scrollWindow(w, layout, !vertical, position);
return 0; return 0;
} }
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL)) if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
@@ -148,8 +179,9 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
remainder += GET_WHEEL_DELTA_WPARAM(wp); remainder += GET_WHEEL_DELTA_WPARAM(wp);
int steps = remainder / WHEEL_DELTA; int steps = remainder / WHEEL_DELTA;
remainder %= WHEEL_DELTA; remainder %= WHEEL_DELTA;
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1); if (steps)
arrangeWindow(w); scrollWindow(w, layout, horizontal, (horizontal ? layout->x : layout->y) +
steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1));
return 0; return 0;
} }
if (msg == WM_NCDESTROY) if (msg == WM_NCDESTROY)
@@ -276,7 +308,7 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RegisterClassW(&wc); RegisterClassW(&wc);
UINT dpi = GetDpiForSystem(); UINT dpi = GetDpiForSystem();
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)}; RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra; DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX; if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi); AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
POINT cursor{}; POINT cursor{};
@@ -420,7 +452,7 @@ void search(HWND w, HistoryView *v)
auto generation = ++v->generation; auto generation = ++v->generation;
v->pending.clear(); v->pending.clear();
auto *app = v->app; auto *app = v->app;
app->enqueue( app->enqueueHistorySearch(
[w, app, q, generation] [w, app, q, generation]
{ {
try try
@@ -625,6 +657,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
int id = LOWORD(wp); int id = LOWORD(wp);
if (id == 101 && HIWORD(wp) == EN_CHANGE) if (id == 101 && HIWORD(wp) == EN_CHANGE)
{ {
// Reject an older result even during the debounce interval.
++v->generation;
SetTimer(w, 1, 180, nullptr); SetTimer(w, 1, 180, nullptr);
return 0; return 0;
} }
@@ -732,7 +766,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin"); AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
AppendMenuW(menu, MF_STRING, 113, L"Delete"); AppendMenuW(menu, MF_STRING, 113, L"Delete");
AppendMenuW(menu, MF_STRING, 114, L"Open full item"); AppendMenuW(menu, MF_STRING, 114, L"Open full item");
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (Windows OCR)"); AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
POINT p; POINT p;
GetCursorPos(&p); GetCursorPos(&p);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr); int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
@@ -1057,6 +1091,99 @@ void showAutoOptions(HWND parent, SettingsView *v)
showRequestedWindow(w); showRequestedWindow(w);
SetForegroundWindow(w); SetForegroundWindow(w);
} }
struct OcrOptionsView { HWND parent; SettingsView *settings; };
Settings ocrDraft(HWND w, OcrOptionsView *v)
{
auto draft = v->settings->draft;
draft.useTesseract = checked(w, 501);
draft.tesseractPath = value(w, 502);
draft.tesseractLanguages = value(w, 503);
validateSettings(draft);
return draft;
}
LRESULT CALLBACK ocrOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *v = reinterpret_cast<OcrOptionsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!v) return DefWindowProcW(w, msg, wp, lp);
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
try
{
if (id == 507)
{
if ((INT_PTR)ShellExecuteW(w, L"open", L"https://github.com/UB-Mannheim/tesseract/wiki#tesseract-installer-for-windows",
nullptr, nullptr, SW_SHOWNORMAL) <= 32)
throw std::runtime_error("Could not open the Tesseract download page in your browser");
return 0;
}
if (id == 504)
{
wchar_t path[32768]{};
OPENFILENAMEW picker{sizeof(picker)};
picker.hwndOwner = w; picker.lpstrFile = path; picker.nMaxFile = std::size(path);
picker.lpstrFilter = L"Tesseract executable (tesseract.exe)\0tesseract.exe\0Executables\0*.exe\0";
picker.Flags = OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR;
if (GetOpenFileNameW(&picker)) value(w, 502, path);
return 0;
}
if (id == 505 || id == IDOK)
{
auto draft = ocrDraft(w, v);
if (id == 505)
{
draft.useTesseract = true;
validateTesseract(draft);
SetDlgItemTextW(w, 506, (L"Ready: " + draft.tesseractLanguages).c_str());
return 0;
}
validateTesseract(draft);
v->settings->draft.useTesseract = draft.useTesseract;
v->settings->draft.tesseractPath = draft.tesseractPath;
v->settings->draft.tesseractLanguages = draft.tesseractLanguages;
DestroyWindow(w);
return 0;
}
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
}
catch (const std::exception &e) { error(w, e); return 0; }
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_NCDESTROY)
{
EnableWindow(v->parent, TRUE);
SetForegroundWindow(v->parent);
disposeFont(w); delete v;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
return DefWindowProcW(w, msg, wp, lp);
}
void showOcrOptions(HWND parent, SettingsView *v)
{
HWND w = makeWindow(*v->app, L"KSwitcher.OcrOptions", L"Image text recognition", ocrOptionsProc, 640, 450);
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600);
checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
check(w, 501, v->draft.useTesseract);
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
edit(w, 502, 18, 118, 484);
value(w, 502, v->draft.tesseractPath);
button(w, L"Browse…", 504, 514, 117, 106);
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
edit(w, 503, 18, 188, 602);
value(w, 503, v->draft.tesseractLanguages);
button(w, L"Check installation", 505, 18, 228, 180);
control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24);
button(w, L"Download Tesseract…", 507, 18, 266, 180);
label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408);
label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602);
label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602);
button(w, L"OK", IDOK, 406, 390, 100);
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
EnableWindow(parent, FALSE);
showRequestedWindow(w); SetForegroundWindow(w);
}
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
if (msg == WM_DPICHANGED) if (msg == WM_DPICHANGED)
@@ -1092,6 +1219,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
showLogs(*v->app); showLogs(*v->app);
return 0; return 0;
} }
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 230) if (id == 230)
{ {
collectLayout(w, v); collectLayout(w, v);
@@ -1685,6 +1813,7 @@ void showSettings(App &app, bool visible)
edit(w, 243, 18, 544, 742); edit(w, 243, 18, 544, 742);
button(w, L"Auto-correction options…", 255, 18, 575, 240); button(w, L"Auto-correction options…", 255, 18, 575, 240);
button(w, L"View log…", 256, 278, 575, 220); button(w, L"View log…", 256, 278, 575, 220);
button(w, L"OCR options…", 257, 518, 575, 242);
button(w, L"Import settings…", 250, 18, 606, 145); button(w, L"Import settings…", 250, 18, 606, 145);
button(w, L"Export settings…", 251, 173, 606, 145); button(w, L"Export settings…", 251, 173, 606, 145);
button(w, L"Snippets…", 252, 328, 606, 110); button(w, L"Snippets…", 252, 328, 606, 110);
+4 -1
View File
@@ -2,9 +2,12 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.3"; inline constexpr wchar_t AppVersion[] = L"0.6.9";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
bool notifyUpdateAvailable(const std::wstring &version);
std::wstring updateToastXml(const std::wstring &version);
struct UpdateResult struct UpdateResult
{ {
std::wstring state, version, error; std::wstring state, version, error;
+128
View File
@@ -4,8 +4,17 @@
#include <commctrl.h> #include <commctrl.h>
#include <iostream> #include <iostream>
#include <cmath> #include <cmath>
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
{
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
return DefSubclassProc(w, message, wp, lp);
}
namespace ks { namespace ks {
struct InputRegressionAccess { struct InputRegressionAccess {
static inline bool delivery = true;
static inline int notifications = 0;
static bool notify(const std::wstring &) { ++notifications; return delivery; }
static void notificationSetup(App &a) { a.updateNotifier = notify; }
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; } static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static bool updateTimer(App &a) { static bool updateTimer(App &a) {
a.updateRunning=true; // Exercise timer scheduling without launching a network request. a.updateRunning=true; // Exercise timer scheduling without launching a network request.
@@ -42,6 +51,7 @@ int main(int argc, char **argv) {
{ {
ks::App app(GetModuleHandleW(nullptr),true,temp); ks::App app(GetModuleHandleW(nullptr),true,temp);
ks::InputRegressionAccess::setup(app); ks::InputRegressionAccess::setup(app);
ks::InputRegressionAccess::notificationSetup(app);
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor. // 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); 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(app.window!=nullptr,"message-only test window");
@@ -49,8 +59,18 @@ int main(int argc, char **argv) {
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks"); 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","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"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version"); require(ks::InputRegressionAccess::updateResult(app,L"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"); require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
ks::InputRegressionAccess::delivery=false;
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
ks::InputRegressionAccess::delivery=true;
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
auto toast=ks::updateToastXml(L"1&2<3\"");
require(toast.find(L"1&amp;2&lt;3&quot;")!=toast.npos,"update version is XML escaped");
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
"toast body and action both review the update instead of installing automatically");
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon) for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
for(int size:{22,44,55}) { 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); auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
@@ -179,6 +199,67 @@ int main(int argc, char **argv) {
require(detached.fromNumber==0,"dropping away from badges remains detached"); 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); 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"); 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; auto lastPoint=linked[2].from;
linked.erase(linked.begin()); linked.erase(linked.begin());
ks::resolveScreenshotLinks(linked); ks::resolveScreenshotLinks(linked);
@@ -509,6 +590,34 @@ int main(int argc, char **argv) {
ks::showHistory(app,false); ks::showHistory(app,false);
auto history=app.historyWindow; auto history=app.historyWindow;
require(history&&!IsWindowVisible(history),"history layout tested without changing focus"); require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
{
app.history=std::make_unique<ks::History>(temp/L"history-search");
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings);
// This App has no general worker: a search on that queue can never finish.
auto originalFocus=GetFocus();
auto pumpUntil=[&](int count) {
auto deadline=GetTickCount64()+3000;
do {
MSG message{};
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
TranslateMessage(&message); DispatchMessageW(&message);
}
if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true;
Sleep(5);
} while(GetTickCount64()<deadline);
return false;
};
ks::refreshHistory(app);
require(pumpUntil(2),"history loads independently of general worker queue");
SetDlgItemTextW(history,101,L"згенеруй");
require(pumpUntil(1),"typing filters history via debounce without focus change");
SetDlgItemTextW(history,101,L"missing query");
require(pumpUntil(0),"unmatched search clears history list");
SetDlgItemTextW(history,101,L"");
require(pumpUntil(2),"clearing search restores history");
require(GetFocus()==originalFocus,"history search preserves focus");
}
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; }; auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
RECT historyFrame{}; GetWindowRect(history,&historyFrame); RECT historyFrame{}; GetWindowRect(history,&historyFrame);
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110); auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
@@ -551,8 +660,27 @@ int main(int argc, char **argv) {
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling"); require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
GetScrollInfo(settings,SB_HORZ,&scroll); GetScrollInfo(settings,SB_HORZ,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling"); require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
int controlMoves = 0;
auto trackedControl = GetDlgItem(settings,201);
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
controlMoves=0;
ValidateRect(settings,nullptr);
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
require(controlMoves==0,"partial wheel step does not rearrange controls");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
require(controlMoves==1,"wheel scroll batches each control movement once");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0); SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0); SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
controlMoves=0;
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
RemoveWindowSubclass(trackedControl,countControlMoves,99);
GetClientRect(settings,&client); GetClientRect(settings,&client);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel); GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2); MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
+46 -4
View File
@@ -1,7 +1,14 @@
#include "ocr.h" #include "ocr.h"
#include "app.h"
#include <iostream> #include <iostream>
int main() namespace ks {
struct InputRegressionAccess {
static void scheduleOcr(App &app) { app.scheduleHistoryOcr(app.settings); }
};
}
int main(int argc, char **argv)
{ {
const bool tesseract = argc == 2 && std::string(argv[1]) == "--tesseract";
CoInitializeEx(nullptr, COINIT_MULTITHREADED); CoInitializeEx(nullptr, COINIT_MULTITHREADED);
try try
{ {
@@ -24,7 +31,8 @@ int main()
auto oldFont = SelectObject(dc, font); auto oldFont = SelectObject(dc, font);
SetTextColor(dc, RGB(0, 0, 0)); SetTextColor(dc, RGB(0, 0, 0));
SetBkColor(dc, RGB(255, 255, 255)); SetBkColor(dc, RGB(255, 255, 255));
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15); std::wstring fixture = tesseract ? L"Привіт Україно HELLO" : L"HELLO KSWITCHER";
TextOutW(dc, 20, 40, fixture.c_str(), (int)fixture.size());
GdiFlush(); GdiFlush();
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)}; ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
SelectObject(dc, oldFont); SelectObject(dc, oldFont);
@@ -36,11 +44,45 @@ int main()
auto decoded = ks::decodeImage(clip); auto decoded = ks::decodeImage(clip);
if (decoded.width != 800 || decoded.height != 150) if (decoded.width != 800 || decoded.height != 150)
throw std::runtime_error("WIC decode size mismatch"); throw std::runtime_error("WIC decode size mismatch");
auto text = ks::recognizeText(clip); ks::Settings config;
config.useTesseract = tesseract;
auto text = ks::recognizeText(clip, config);
std::cout << "OCR: " << ks::utf8(text) << '\n'; std::cout << "OCR: " << ks::utf8(text) << '\n';
if (ks::upper(text).find(L"HELLO") == std::wstring::npos) if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
throw std::runtime_error("OCR fixture was not recognized"); throw std::runtime_error("OCR fixture was not recognized");
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n"; if (tesseract && ks::lower(text).find(L"привіт") == std::wstring::npos)
throw std::runtime_error("Ukrainian OCR fixture was not recognized");
auto temp = std::filesystem::temp_directory_path() / (L"KSwitcher-auto-ocr-" + std::to_wstring(GetCurrentProcessId()));
auto sequence = GetClipboardSequenceNumber();
{
ks::App app(GetModuleHandleW(nullptr), true, temp);
app.settings.history = true;
app.history->add(clip, L"OCR fixture", app.settings);
auto id = app.history->entries().front().id;
if (tesseract)
{
app.history->saveOcr(id, L"old Windows result", app.settings);
app.settings.useTesseract = true;
}
ks::InputRegressionAccess::scheduleOcr(app);
auto deadline = GetTickCount64() + 35000;
while ((app.history->entries().front().ocrState == 1 ||
app.history->entries().front().ocrProfile != ks::ocrProfile(app.settings)) &&
GetTickCount64() < deadline) Sleep(20);
if (app.history->search(L"HELLO").size() != 1)
throw std::runtime_error("Background OCR did not make the image searchable");
ks::History restored(temp / L"history");
if (!restored.ocrText(id) || restored.search(L"HELLO").size() != 1)
throw std::runtime_error("Background OCR did not persist its result");
if (tesseract && (restored.search(L"привіт").size() != 1 || !restored.search(L"old Windows result").empty()))
throw std::runtime_error("Engine change did not replace cached Windows OCR with Ukrainian text");
if (GetClipboardSequenceNumber() != sequence)
throw std::runtime_error("Background OCR changed the clipboard");
std::cout << "PASS automatic background OCR, persisted search and unchanged clipboard\n";
}
std::filesystem::remove_all(temp);
std::cout << (tesseract ? "PASS optional Tesseract, mixed Ukrainian/English and cache migration\n" :
"PASS native Windows OCR, DIB decoding and async result retrieval\n");
CoUninitialize(); CoUninitialize();
return 0; return 0;
} }
+22
View File
@@ -3,6 +3,14 @@
#include <shlobj.h> #include <shlobj.h>
#include <iostream> #include <iostream>
static std::wstring programs; static std::wstring programs;
static std::wstring activationExecutable, activationCommand;
static int activationCalls{};
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
{
++activationCalls; activationExecutable=path; activationCommand=command;
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
}
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out) static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
{ {
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t)); *out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
@@ -10,8 +18,10 @@ static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
wcscpy(*out,programs.c_str()); return S_OK; wcscpy(*out,programs.c_str()); return S_OK;
} }
#define SHGetKnownFolderPath testKnownFolder #define SHGetKnownFolderPath testKnownFolder
#define CreateProcessW testCreateProcess
#include "../src/screenshot_activation.cpp" #include "../src/screenshot_activation.cpp"
#undef SHGetKnownFolderPath #undef SHGetKnownFolderPath
#undef CreateProcessW
int main() int main()
{ {
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
@@ -45,6 +55,18 @@ int main()
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id); valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
if(!valid) throw std::runtime_error("shortcut activator missing"); if(!valid) throw std::runtime_error("shortcut activator missing");
ks::registerScreenshotNotifications(); // Registration is safe to repeat. ks::registerScreenshotNotifications(); // Registration is safe to repeat.
ks::Activator activator(helper.wstring(),false);
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
if(activationCalls!=1 || activationExecutable!=main || activationCommand!=L"\""+main+L"\" --check-update")
throw std::runtime_error("update toast must open review, never install automatically");
if(activator.Activate(L"other-app",L"check-update",nullptr,0)!=E_INVALIDARG ||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"--install",nullptr,0)!=E_INVALIDARG || activationCalls!=1)
throw std::runtime_error("invalid notification activation must not launch a process");
const wchar_t *token=L"12345678-1234-1234-1234-123456789abc";
activator.Activate(L"HrynCo.KSwitcher.Screenshot",token,nullptr,0);
if(activationCalls!=2 || activationExecutable!=helper.wstring() || activationCommand.find(token)==activationCommand.npos)
throw std::runtime_error("screenshot activation must retain its existing route");
std::cout<<"Passed isolated screenshot registration checks\n"; std::cout<<"Passed isolated screenshot registration checks\n";
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; } } catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr); RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
+164 -2
View File
@@ -22,6 +22,8 @@ int main()
"editor is an independent Alt+Tab application window"); "editor is an independent Alt+Tab application window");
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created"); require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads"); require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
"new screenshot editor starts with the pencil tool");
for(int size:{32,48,64,96}) { for(int size:{32,48,64,96}) {
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0); auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
ICONINFO info{}; ICONINFO info{};
@@ -31,16 +33,58 @@ int main()
if(info.hbmColor) DeleteObject(info.hbmColor); if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor); DestroyCursor(cursor);
} }
ks::Surface cursorPreview(784,448);
PatBlt(cursorPreview.dc,0,0,784,448,WHITENESS);
int row=0;
for(int size:{32,48,64,96}) {
int column=0;
for(int tool:ks::CursorTools) {
auto cursor=ks::makeToolCursor(tool,size); ICONINFO info{};
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"tool cursors support standard and high DPI");
require(info.xHotspot==UINT(MulDiv(7,size,32)) && info.yHotspot==info.xHotspot,"tool cursor hotspot stays on crosshair center");
BITMAP mask{}; GetObjectW(info.hbmMask,sizeof(mask),&mask);
require(mask.bmWidth==size && mask.bmHeight==size*2,"cursor transparency mask matches requested DPI");
if(column%2) {
RECT tile{column*112,row*112,(column+1)*112,(row+1)*112};
auto dark=CreateSolidBrush(RGB(40,40,40)); FillRect(cursorPreview.dc,&tile,dark); DeleteObject(dark);
}
DrawIconEx(cursorPreview.dc,column*112,row*112,cursor,size,size,0,nullptr,DI_NORMAL);
if(info.hbmMask) DeleteObject(info.hbmMask);
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor); ++column;
}
++row;
}
auto dib=ks::screenshotClip(cursorPreview.dc,784,448,{}).formats[0].data;
BITMAPFILEHEADER bmp{}; bmp.bfType=0x4d42; bmp.bfOffBits=sizeof(bmp)+sizeof(BITMAPINFOHEADER); bmp.bfSize=DWORD(sizeof(bmp)+dib.size());
ks::Bytes preview(sizeof(bmp)); memcpy(preview.data(),&bmp,sizeof(bmp)); preview.insert(preview.end(),dib.begin(),dib.end());
ks::atomicWrite(L"build/cursor-preview.bmp",preview);
for(int tool:{27,26,21,10,11,22,23,24,25,19}) {
SendMessageW(window,WM_COMMAND,tool,0);
HCURSOR expected=nullptr;
if(tool==27) expected=LoadCursorW(nullptr,IDC_ARROW);
else if(tool==26) expected=LoadCursorW(nullptr,IDC_IBEAM);
else if(tool==21) expected=c.pencilCursor;
else for(size_t i=0;i<std::size(ks::CursorTools);++i) if(ks::CursorTools[i]==tool) expected=c.drawingCursors[i];
require(expected && ks::actionCursor(c,{-1,ks::MarkPart::None},true)==expected,"each tool uses its own drawing cursor");
require(ks::actionCursor(c,{-1,ks::MarkPart::None},false)==LoadCursorW(nullptr,IDC_ARROW),"outside canvas uses normal pointer");
c.marks={{{20,20},{120,20},0}};
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEWE),"endpoint resize overrides drawing tool");
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
}
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128); wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints"); require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
RECT client{}; GetClientRect(window,&client); RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar"); require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c); ks::updateStatus(c);
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); SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut"); require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0); 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"); 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"); 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; ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
@@ -94,6 +138,124 @@ int main()
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c); c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping"); require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
c.hoveredPart=ks::MarkPart::None; 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; std::vector<ks::Mark> nudgeMarks;
nudgeMarks.push_back({{60,60},{60,60},1}); nudgeMarks.push_back({{60,60},{60,60},1});
nudgeMarks.push_back({{60,60},{120,100},0}); nudgeMarks.push_back({{60,60},{120,100},0});
+69
View File
@@ -468,6 +468,75 @@ int main(int argc, char **argv)
history.clear(); history.clear();
require(history.entries().empty(), "clear history"); require(history.entries().empty(), "clear history");
} }
{
ks::History history(temp / L"search");
history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings);
history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings);
require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview");
require(history.search(L"first\nsecond").size() == 1, "search retains original newlines");
require(history.search(L"second\tthird").size() == 1, "search retains original tabs");
require(history.search(L"absent").empty(), "short previews reject unmatched queries");
require(history.search(L"FIRST").size() == 1, "short preview search ignores case");
}
{
auto folder = temp / L"ocr-history";
ks::History history(folder);
ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}};
history.add(image, L"OCR test", settings);
auto id = history.entries().front().id;
require(history.entries().front().ocrState == 1, "new images await background OCR");
require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart");
auto original = ks::encodeClip(history.load(id));
auto beforeBytes = history.entries().front().bytes;
require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR");
require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size");
require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats");
require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten");
ks::History restored(folder);
require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable");
require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart");
auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024);
require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted");
restored.remove(id);
require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar");
require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image");
restored.add(image, L"OCR test", settings);
id = restored.entries().front().id;
restored.saveOcr(id, L"", settings);
require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result");
restored.clear();
require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars");
restored.add(image, L"OCR test", settings);
id = restored.entries().front().id;
restored.saveOcr(id, std::nullopt, settings);
require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart");
restored.saveOcr(id, L"manual retry", settings);
require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR");
restored.add(ks::textClip(L"newer"), L"test", settings);
auto limited = settings; limited.maxItems = 1;
restored.add(ks::textClip(L"newest"), L"test", limited);
require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar");
}
{
ks::Settings options;
require(!options.useTesseract, "Tesseract is opt-in by default");
options.useTesseract = true;
options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe";
options.tesseractLanguages = L"ukr+eng+rus";
auto restored = ks::decodeSettings(ks::encodeSettings(options), {});
require(restored.useTesseract && restored.tesseractPath == options.tesseractPath &&
restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip");
options.tesseractLanguages = L"ukr+../eng";
rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected");
options.tesseractLanguages = L"ukr++eng";
rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected");
options.tesseractLanguages = L"ukr+eng";
options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe";
rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure");
options.useTesseract = false;
ks::validateTesseract(options);
require(true, "Windows OCR works without optional installation");
}
// Test-owned directory only; never touch the user's clipboard or stored history. // Test-owned directory only; never touch the user's clipboard or stored history.
std::filesystem::remove_all(temp); std::filesystem::remove_all(temp);
CoUninitialize(); CoUninitialize();