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