Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c61ae156b4 |
@@ -1,35 +0,0 @@
|
|||||||
# Project instructions
|
|
||||||
|
|
||||||
## Mandatory Wiki updates for every release
|
|
||||||
|
|
||||||
A release is not complete until the published project Wiki reflects that release.
|
|
||||||
Apply this rule to every release, including patch releases and bug-fix-only releases.
|
|
||||||
|
|
||||||
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
|
|
||||||
|
|
||||||
1. Confirm the release version from `CMakeLists.txt`, the release tag and
|
|
||||||
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
|
|
||||||
preserve the release channel rules in `README.md`.
|
|
||||||
2. Update the latest-version information on the Wiki home pages and any other
|
|
||||||
pages identifying the current release. Update release dates, download links
|
|
||||||
and upgrade instructions where needed. Keep historical version references
|
|
||||||
and compatibility bridge instructions when they are still accurate.
|
|
||||||
3. Review the changes since the last release documented in the Wiki, not just
|
|
||||||
the immediately preceding release. Bring the relevant feature pages up to
|
|
||||||
date with the shipped behavior: new, changed and removed functionality,
|
|
||||||
settings, shortcuts, defaults, limitations and troubleshooting. Update
|
|
||||||
screenshots or examples when they no longer match the application.
|
|
||||||
For bug-fix-only releases, check whether documented behavior or workarounds
|
|
||||||
need correction; do not invent feature changes.
|
|
||||||
4. Keep English and Ukrainian pages synchronized, including `Home` and
|
|
||||||
`Home-uk`, and preserve working translation and navigation links.
|
|
||||||
5. Publish the Wiki changes as part of the release workflow. Read back the
|
|
||||||
published pages and verify the current version, feature descriptions,
|
|
||||||
download links and translation links. A local Wiki edit alone is not enough.
|
|
||||||
6. In the release completion report, link the updated Wiki pages and summarize
|
|
||||||
the documentation changes. If Wiki publication or verification is blocked,
|
|
||||||
explicitly report the outstanding step and do not claim the release workflow
|
|
||||||
is complete.
|
|
||||||
|
|
||||||
Updating only the release notes or changing only the Wiki version number does
|
|
||||||
not satisfy this rule; the current functionality must also be reviewed.
|
|
||||||
+9
-17
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.18 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.4.2 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
@@ -8,8 +8,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|||||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||||
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||||
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
@@ -18,36 +18,28 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
|||||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||||
add_executable(KSwitcherTests tests/tests.cpp)
|
add_executable(KSwitcherTests tests/tests.cpp)
|
||||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||||
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
|
|
||||||
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
|
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
|
||||||
target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES)
|
|
||||||
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc)
|
|
||||||
set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot")
|
|
||||||
target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES)
|
|
||||||
target_link_libraries(KSwitcherScreenshot PRIVATE kscore)
|
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
|
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
|
||||||
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
else()
|
else()
|
||||||
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherOcrTests tests/ocr.cpp)
|
||||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
|
|
||||||
else()
|
else()
|
||||||
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
|
||||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
@@ -59,7 +51,7 @@ add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
|||||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .)
|
||||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||||
|
|
||||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://git
|
|||||||
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
||||||
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
||||||
|
|
||||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
|
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
|
||||||
|
|
||||||
[](https://send.monobank.ua/jar/88h2TddKfS)
|
|
||||||
|
|
||||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -16,29 +12,16 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
|||||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||||
Double-click the tray icon to open Settings, the bold default menu item.
|
Double-click the tray icon to open Settings, the bold default menu item.
|
||||||
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||||
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
in the resident app. Keep both executables together when copying or updating the application.
|
||||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
|
||||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
|
||||||
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
|
|
||||||
stable numbered titles within that process. Keep all three executables together when copying or
|
|
||||||
updating the application. Close screenshot editors before installing an update.
|
|
||||||
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
||||||
the configurator to load the latest settings.
|
the configurator to load the latest settings.
|
||||||
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
|
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
|
||||||
Update in the tray menu. Select Update again to install the full current release;
|
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
|
||||||
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
|
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
|
||||||
tags, which the bridge and newer clients recognize. Older clients ignore those tags
|
|
||||||
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
|
|
||||||
still require the previously documented initial configurator migration.
|
|
||||||
|
|
||||||
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
|
|
||||||
hashes). The updater validates that every packaged file is declared, all hashes match,
|
|
||||||
and paths stay within the installation directory. `data/` is protected; existing
|
|
||||||
dictionaries are preserved. Adding a new program file no longer needs a new updater.
|
|
||||||
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
||||||
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
||||||
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
||||||
@@ -75,29 +58,9 @@ physical key positions and therefore reset recent-word tracking; selection conve
|
|||||||
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
||||||
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
||||||
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
||||||
The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
|
The editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately,
|
||||||
is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
|
or annotate first: **N** places sequential numbered red markers with a click, and **A** draws
|
||||||
to open it automatically; this option is off by default. An immediate notification error still opens the editor.
|
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
|
||||||
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
|
|
||||||
notification center without showing a banner.
|
|
||||||
The preference applies to each new capture, including while other editors are open.
|
|
||||||
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
|
|
||||||
at resident startup and refreshed before sending; registration/submission errors are written to the log.
|
|
||||||
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
|
|
||||||
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
|
|
||||||
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
|
|
||||||
broker can load them even when the profile directory is inaccessible to it.
|
|
||||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
|
||||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
|
||||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
|
||||||
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
|
||||||
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
|
||||||
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
|
||||||
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
|
|
||||||
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
|
|
||||||
cursors scale with display DPI and use a contrasting outline for light and dark images.
|
|
||||||
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
|
||||||
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
|
|
||||||
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
||||||
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
||||||
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
||||||
@@ -107,14 +70,6 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
|||||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||||
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
|
|
||||||
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
|
||||||
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
|
||||||
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
|
|
||||||
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
|
||||||
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
|
||||||
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
|
||||||
leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected.
|
|
||||||
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
||||||
and its keyboard shortcut.
|
and its keyboard shortcut.
|
||||||
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
||||||
@@ -143,8 +98,6 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
|
|||||||
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
||||||
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
||||||
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
||||||
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
|
|
||||||
is reused; rotation belongs to each individual object.
|
|
||||||
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
||||||
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
||||||
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
||||||
@@ -160,19 +113,15 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin
|
|||||||
for shapes.
|
for shapes.
|
||||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||||
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||||
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
|
||||||
While holding Shift, moving more than 50 image pixels away from the current line selects a new
|
|
||||||
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
|
|
||||||
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
|
|
||||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
The window close button asks for confirmation before discarding an uncopied screenshot.
|
||||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy closes the editor and publishes the image
|
||||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||||
The shortcut is configurable in Settings under **Screen capture**.
|
The shortcut is configurable in Settings under **Screen capture**.
|
||||||
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
|
Each capture opens an independent editor; use the shortcut again to take another screenshot while
|
||||||
keeping earlier editors and their annotations open.
|
keeping earlier editors and their annotations open.
|
||||||
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
||||||
|
|
||||||
@@ -180,23 +129,8 @@ Captures use ordinary clipboard history settings and limits. Protected video and
|
|||||||
|
|
||||||
Select **Update** in the tray menu to check the stable Releases of
|
Select **Update** in the tray menu to check the stable Releases of
|
||||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||||
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
A startup check also reports an available version, but never installs it automatically.
|
||||||
It reports each available version once per session and never installs automatically.
|
Confirm the offered version to download and install it. The updater uses the existing
|
||||||
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. A progress window shows
|
|
||||||
download percentage and received/total size, package verification,
|
|
||||||
installation and restart. After the new process confirms startup, that window shows the installed
|
|
||||||
version and a Done button; it does not send a separate completion notification. If installation
|
|
||||||
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
|
||||||
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
|
||||||
the currently installed build, so it becomes available for subsequent updates after installing it.
|
|
||||||
|
|
||||||
The updater uses the existing
|
|
||||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||||
@@ -214,8 +148,6 @@ controls and fonts. Settings has a fixed form size, without manual resizing or m
|
|||||||
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
||||||
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||||
field into view; Shift+wheel scrolls horizontally.
|
field into view; Shift+wheel scrolls horizontally.
|
||||||
Clipboard history also updates its preview row heights when DPI changes, keeping text,
|
|
||||||
thumbnails and source labels aligned with the scaled fonts and spacing.
|
|
||||||
|
|
||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
@@ -228,10 +160,7 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||||
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||||
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
|
||||||
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
|
||||||
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
|
||||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||||
full-text search, pinning, deletion, full text viewing and image previews.
|
full-text search, pinning, deletion, full text viewing and image previews.
|
||||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||||
@@ -243,33 +172,16 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
|
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
|
||||||
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
|
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
|
||||||
metadata (including Snipping Tool's repeated clipboard publication).
|
metadata (including Snipping Tool's repeated clipboard publication).
|
||||||
- New images are recognized automatically in a separate background worker using native, offline
|
- Windows OCR, entirely native and offline. Right-click an image in history and select
|
||||||
Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search,
|
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
|
||||||
without changing the image or clipboard. Completed results (including empty text) are reused;
|
|
||||||
pending work resumes after restart. Older images without OCR are processed once in the background,
|
|
||||||
starting with the newest entries.
|
|
||||||
Right-click an image and select **Copy text from image** to copy the saved text or explicitly
|
|
||||||
retry recognition. Requires a Windows OCR language installed for the user profile; automatic
|
|
||||||
failures are logged quietly. OCR sidecars count toward the history size limit and are removed
|
|
||||||
with their images.
|
|
||||||
- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains
|
|
||||||
the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package
|
|
||||||
separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable
|
|
||||||
path blank to detect a standard installation, or select `tesseract.exe`; its neighboring
|
|
||||||
`libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation**
|
|
||||||
validates the selected models. **Download Tesseract…** opens the Windows installer page in your
|
|
||||||
browser. No packages or models are downloaded automatically.
|
|
||||||
Tesseract processes screenshots locally in memory with a 30-second recognition deadline.
|
|
||||||
Changing engine, executable path or languages refreshes cached image text in the background.
|
|
||||||
- History exclusion flags and excluded processes, pause/resume, count/size limits.
|
- History exclusion flags and excluded processes, pause/resume, count/size limits.
|
||||||
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
|
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
|
||||||
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
|
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
|
||||||
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
||||||
|
|
||||||
Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||||
Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
|
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
|
||||||
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
|
|
||||||
|
|
||||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||||
@@ -282,38 +194,25 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
|||||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||||
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
|
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
||||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
Only failures flush this context to `kswitcher.log`; flushed
|
||||||
context. Successful operations stay in that bounded memory buffer and are included only when
|
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
||||||
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
|
||||||
errors are also logged when logging is enabled. This works in normal Release builds.
|
|
||||||
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
||||||
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
||||||
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
||||||
original length and a truncation flag; context fields are limited to 512 UTF-16 code units.
|
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
|
||||||
Structured summaries retain up to 4096 UTF-16 code units per text field and mark truncation.
|
|
||||||
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
||||||
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
||||||
record the process, window, input version, and repeated attempts within ten seconds without new
|
record the process, window, input version, and repeated attempts within ten seconds without new
|
||||||
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
||||||
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
An unchanged result containing no letters, or a direct command
|
||||||
checks the focused editor without moving the caret, changing selection, or copying again.
|
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
||||||
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
|
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||||
**Paste not verified**. Unsupported/protected controls, changed focus, and readback limits are
|
|
||||||
explicit verification reasons, never proof of success. Readback is bounded to 4096 characters;
|
|
||||||
provider-specific character units or delayed editor updates may prevent verification.
|
|
||||||
With logging disabled, this additional accessibility check is skipped.
|
|
||||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||||
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
|
|
||||||
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
|
|
||||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||||
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||||
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
|
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||||
Select a row to inspect original, expected and read-back text, verification status and stages
|
|
||||||
in the panel below. **Copy selected** or Ctrl+C copies the selected rows with their details;
|
|
||||||
Ctrl+A selects all rows. Older unstructured log entries remain readable. Viewing saved logs
|
|
||||||
also works when logging is disabled.
|
|
||||||
**Clear log** clears only the selected current or previous file and refreshes the table.
|
**Clear log** clears only the selected current or previous file and refreshes the table.
|
||||||
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||||
|
|
||||||
@@ -371,27 +270,15 @@ after Space, not immediately after a punctuation key. Save Settings to apply cha
|
|||||||
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
||||||
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
||||||
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
||||||
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
|
||||||
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
|
||||||
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
|
||||||
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
|
||||||
are eligible as correction targets at any frequency limit. The language and target layout must still
|
|
||||||
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
|
||||||
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
||||||
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
||||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
|
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and
|
||||||
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
|
||||||
files before replacing dictionaries with a new distribution.
|
files before replacing dictionaries with a new distribution.
|
||||||
|
|
||||||
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
||||||
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
|
||||||
key presses and releases; no typing pause is required. Queued input is replayed after
|
It only corrects unknown words when exactly one alternative is a dictionary word.
|
||||||
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
|
||||||
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
|
||||||
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
|
||||||
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
|
||||||
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
|
||||||
Different valid spellings remain ambiguous and are left unchanged.
|
|
||||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||||
does not count toward this minimum; the maximum word length is 32 letters.
|
does not count toward this minimum; the maximum word length is 32 letters.
|
||||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||||
@@ -460,21 +347,12 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
|
|||||||
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
|
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
|
||||||
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
||||||
|
|
||||||
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
|
## Compatibility update bridge (v0.4.2)
|
||||||
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
|
|
||||||
|
|
||||||
### Release channel rule
|
This two-executable release can be installed by the v0.3.x–v0.4.1 updater.
|
||||||
|
It upgrades the updater to discover stable `release-vX.Y.Z` tags and validate
|
||||||
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
|
manifest-based packages. Use Update again after this version starts to install
|
||||||
the latest version and relevant feature documentation in both English and Ukrainian.
|
the full current release, including the standalone screenshot editor.
|
||||||
Review all changes since the last documented release, update affected instructions,
|
The screenshot editor remains embedded only in this transitional release.
|
||||||
shortcuts, settings and screenshots, and verify the published pages and links.
|
Future full releases must use the `release-v` prefix so unmigrated clients
|
||||||
The release workflow is incomplete until this is done. Follow the mandatory
|
continue to discover v0.4.2 first rather than skip this bridge.
|
||||||
[Wiki release checklist](AGENTS.md#mandatory-wiki-updates-for-every-release).
|
|
||||||
|
|
||||||
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
|
||||||
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
|
|
||||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
|
||||||
legacy version tag: clients that have not installed the bridge would select it.
|
|
||||||
Keep the compatibility bridge in the first 100 releases returned by Gitea while
|
|
||||||
supporting old clients; their immutable update code only reads that page.
|
|
||||||
|
|||||||
Binary file not shown.
+21
-15
@@ -1,23 +1,28 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.18 — 2026-10-31
|
## v0.4.1 — 2026-10-28
|
||||||
|
|
||||||
## Changes
|
- Fix Settings spacing around Screen capture, automatic layout correction and layout controls.
|
||||||
- Verify the actual editor text after manual conversion using read-only Windows Accessibility, without moving the caret, changing selection or copying again. Record unchanged calculations, original text remaining, unexpected readback and unavailable verification separately.
|
- Verify update checksums through .NET SHA-256 without requiring the PowerShell Get-FileHash command. This fixes updates from processes launched with a conflicting PowerShell module environment.
|
||||||
- Replace the event-only diagnostic list with sortable Time, Program, Command, Source layout, Target layout and Result columns; add program/command/result filters and a separate panel with original, expected and read-back text and operation context.
|
- Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows.
|
||||||
- Keep successful operations only in a bounded in-memory context (40 events, 30 seconds). Only problem operations create file rows; verified successes do not fill the log. Diagnostics work in normal Release builds and the additional readback check is skipped when logging is disabled.
|
- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text.
|
||||||
- Refresh English and Ukrainian Wiki documentation through v0.6.18, including previously undocumented snippet, updater and constrained-pencil changes since v0.6.14.
|
- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions.
|
||||||
|
- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps.
|
||||||
|
- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps.
|
||||||
|
- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor.
|
||||||
|
- Preserve the separate on-demand configurator and configurable capture shortcut.
|
||||||
|
|
||||||
## Limitations
|
Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks,
|
||||||
Some editors do not expose their text through Windows Accessibility. Changed focus, protected or unsupported controls, provider-specific character units, delayed text updates and the 4096-character readback limit can prevent verification; these cases are recorded as unverified rather than successful. Historical unstructured entries remain readable without retroactive filter fields.
|
29 updater checks, including missing Get-FileHash coverage, and package validation.
|
||||||
|
|
||||||
## Validation
|
**Upgrade from v0.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported.
|
||||||
Passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks, updater progress checks, 45 isolated configurator process checks, conversion-specific editor integration and diagnostic viewer checks. Verified that 1000 successful operations create no log file and that a subsequent failure creates one row with bounded context. The broader editor integration suite still intermittently fails in snippet Enter/concurrent-input scenarios; similar failures reproduce with the pre-change code.
|
If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer
|
||||||
|
(outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency.
|
||||||
|
**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE.
|
||||||
|
Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables,
|
||||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
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.
|
|
||||||
|
|
||||||
Build and package:
|
Build and package:
|
||||||
|
|
||||||
@@ -26,4 +31,5 @@ Build and package:
|
|||||||
.\package.ps1 -PublishRelease
|
.\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 whose
|
||||||
|
tag matches the executable version.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3e76d8a566c623a3084c00cc909f4cc7c8a6edb38f9c06adbb32edfd4cfcb5b7 KSwitcher-win-x64.zip
|
7f0ee55eb5b56a6227431c1951ac2751208d3d0c7c34e4d1d616e180c37b1d9f KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
|||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
try {
|
||||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||||
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','app','ui','main','configuration','config_main')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
}
|
}
|
||||||
@@ -24,54 +24,38 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
||||||
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||||
& 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
|
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/screenshot_notifications.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.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.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/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
& $compiler @common tests/screenshot_ui.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/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||||
& .\build\KSwitcherScreenshotUITests.exe
|
& .\build\KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
|
||||||
try {
|
|
||||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
|
||||||
& $registrationTest
|
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
|
||||||
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
|
|
||||||
|
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
|
||||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
|
||||||
}
|
}
|
||||||
if ($Integration) {
|
if ($Integration) {
|
||||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.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/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
}
|
}
|
||||||
if ($Ocr) {
|
if ($Ocr) {
|
||||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/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
|
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||||
& .\build\KSwitcherOcrTests.exe
|
& .\build\KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||||
|
|||||||
+2
-12
@@ -17,19 +17,9 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
|
|||||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||||
|
|
||||||
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
|
File format: UTF-8 word, TAB, positive original frequency rank, newline.
|
||||||
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
|
||||||
They are included with the default unlimited rank setting and recorded in sources.json.
|
|
||||||
|
|
||||||
File format: UTF-8 word, TAB, positive frequency rank (or supplemental rank), newline.
|
|
||||||
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
||||||
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
||||||
Prefer Settings > Auto-correction options > My dictionary for personal words.
|
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator.
|
||||||
Enter correct spellings in the matching language field, separated by commas or new lines.
|
|
||||||
These words are stored in settings, survive dictionary updates, and need no ranks or sorting.
|
|
||||||
Click OK, then Save in Settings to apply them.
|
|
||||||
|
|
||||||
For advanced file edits, personal words may use rank 1. Keep entries lowercase with a Tab separator.
|
|
||||||
Words may be appended: loading sorts entries and merges duplicates using the lowest rank.
|
|
||||||
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
||||||
Back up edited files before replacing dictionaries with a new distribution.
|
Back up edited files before replacing dictionaries with a new distribution.
|
||||||
|
|||||||
@@ -9,12 +9,9 @@
|
|||||||
"uk": {
|
"uk": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||||
"words": 42550,
|
"words": 42549,
|
||||||
"bytes": 898227,
|
"bytes": 898210,
|
||||||
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
|
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
|
||||||
"supplementalWords": {
|
|
||||||
"\u0440\u0435\u043b\u0456\u0437": 50001
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"ru": {
|
"ru": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
||||||
|
|||||||
@@ -31207,7 +31207,6 @@
|
|||||||
релігію 26138
|
релігію 26138
|
||||||
релігія 22202
|
релігія 22202
|
||||||
релігії 23427
|
релігії 23427
|
||||||
реліз 50001
|
|
||||||
рембо 28603
|
рембо 28603
|
||||||
ремень 23872
|
ремень 23872
|
||||||
ремені 28385
|
ремені 28385
|
||||||
|
|||||||
+1
-11
@@ -5,22 +5,12 @@ $destination = Join-Path $root 'dist\KSwitcher'
|
|||||||
New-Item -ItemType Directory -Force $destination | Out-Null
|
New-Item -ItemType Directory -Force $destination | Out-Null
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
|
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
$files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||||
$entries = foreach ($path in $files) {
|
|
||||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
|
||||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
|
|
||||||
$manifest=Join-Path $destination 'release-files.json'
|
|
||||||
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
|
|
||||||
$files += $manifest
|
|
||||||
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
||||||
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||||
|
|||||||
+3
-9
@@ -2,15 +2,12 @@
|
|||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include "resource.h"
|
#include "resource.h"
|
||||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
IDI_KSWITCHER ICON "kswitcher.ico"
|
||||||
#ifdef KS_SCREENSHOT_RESOURCES
|
|
||||||
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
|
|
||||||
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||||
#endif
|
|
||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,18,0
|
FILEVERSION 0,4,2,0
|
||||||
PRODUCTVERSION 0,6,18,0
|
PRODUCTVERSION 0,4,2,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +17,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.18"
|
VALUE "FileVersion", "0.4.2"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
@@ -28,7 +25,6 @@ BEGIN
|
|||||||
VALUE "Translation", 0x409, 1200
|
VALUE "Translation", 0x409, 1200
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
#ifdef KS_SCREENSHOT_RESOURCES
|
|
||||||
300 ICON "icons/lucide/circle-dot-normal.ico"
|
300 ICON "icons/lucide/circle-dot-normal.ico"
|
||||||
310 ICON "icons/lucide/circle-dot-active.ico"
|
310 ICON "icons/lucide/circle-dot-active.ico"
|
||||||
320 ICON "icons/lucide/circle-dot-disabled.ico"
|
320 ICON "icons/lucide/circle-dot-disabled.ico"
|
||||||
@@ -77,5 +73,3 @@ END
|
|||||||
405 ICON "icons/lucide/a-large-small-normal.ico"
|
405 ICON "icons/lucide/a-large-small-normal.ico"
|
||||||
415 ICON "icons/lucide/a-large-small-active.ico"
|
415 ICON "icons/lucide/a-large-small-active.ico"
|
||||||
425 ICON "icons/lucide/a-large-small-disabled.ico"
|
425 ICON "icons/lucide/a-large-small-disabled.ico"
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
@@ -1,14 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="blue" x1="8" y1="4" x2="36" y2="44" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#38bdf8"/>
|
|
||||||
<stop offset=".55" stop-color="#1687e8"/>
|
|
||||||
<stop offset="1" stop-color="#2563eb"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="24" cy="25" r="21" fill="#1e40af" opacity=".12"/>
|
|
||||||
<circle cx="24" cy="23" r="21" fill="url(#blue)"/>
|
|
||||||
<circle cx="24" cy="23" r="20.5" fill="none" stroke="white" stroke-opacity=".25"/>
|
|
||||||
<path d="M18 17.5c0-3.1 2.5-5 6-5s6 2 6 5c0 4.8-6 4.8-6 9" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round"/>
|
|
||||||
<circle cx="24" cy="33" r="2" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 711 B |
@@ -1,4 +1,3 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#define IDI_KSWITCHER 101
|
#define IDI_KSWITCHER 101
|
||||||
#define IDI_SCREENSHOT_QUESTION 102
|
|
||||||
#define IDC_SCREENSHOT_PENCIL 500
|
#define IDC_SCREENSHOT_PENCIL 500
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
@@ -1,22 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
|
||||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
|
||||||
<g font-family="Segoe UI" fill="#241B46">
|
|
||||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
|
|
||||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
|
|
||||||
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
|
|
||||||
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
|
|
||||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
|
||||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(759 48) rotate(10 66 66)">
|
|
||||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
|
||||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
|
||||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
|
||||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
|
||||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
|
||||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
|
||||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
|
||||||
</g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
@@ -1,22 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
|
||||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
|
||||||
<g font-family="Segoe UI" fill="#241B46">
|
|
||||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
|
|
||||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
|
|
||||||
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
|
|
||||||
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
|
|
||||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
|
||||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(759 48) rotate(10 66 66)">
|
|
||||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
|
||||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
|
||||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
|
||||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
|
||||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
|
||||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
|
||||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
|
||||||
</g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
+3
-213
@@ -3,192 +3,10 @@
|
|||||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||||
[switch]$ShowProgress,
|
|
||||||
[switch]$Library
|
[switch]$Library
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$script:progressWindow = $null
|
|
||||||
|
|
||||||
function Start-UpdateProgress {
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
if (!('KSwitcherUpdateProgress' -as [type])) {
|
|
||||||
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
|
||||||
using System;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Windows.Forms;
|
|
||||||
using System.Drawing.Drawing2D;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
public sealed class UpdateMeter : Control {
|
|
||||||
public int Percent = -1;
|
|
||||||
private int position;
|
|
||||||
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
|
||||||
public UpdateMeter() {
|
|
||||||
DoubleBuffered = true;
|
|
||||||
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
|
||||||
timer.Start();
|
|
||||||
}
|
|
||||||
protected override void OnPaint(PaintEventArgs e) {
|
|
||||||
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
|
||||||
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
|
||||||
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
|
||||||
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
|
||||||
}
|
|
||||||
public sealed class UpdateSteps : Control {
|
|
||||||
public int Current = -1;
|
|
||||||
public bool Complete;
|
|
||||||
public UpdateSteps() { DoubleBuffered = true; }
|
|
||||||
protected override void OnPaint(PaintEventArgs e) {
|
|
||||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
|
||||||
string[] names = {"Download","Verify","Install","Restart"};
|
|
||||||
for(int i=0;i<4;i++) {
|
|
||||||
int x = i*(Width-80)/3;
|
|
||||||
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
|
||||||
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
|
||||||
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
|
||||||
using(var brush = new SolidBrush(color)) {
|
|
||||||
if(Complete || i<Current) {
|
|
||||||
e.Graphics.FillEllipse(brush,x,0,16,16);
|
|
||||||
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
|
||||||
} else {
|
|
||||||
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
|
||||||
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
|
||||||
}
|
|
||||||
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public sealed class KSwitcherUpdateProgress : IDisposable {
|
|
||||||
private Form form;
|
|
||||||
private Label stage, detail, percentage, footer;
|
|
||||||
private UpdateMeter progress;
|
|
||||||
private UpdateSteps steps;
|
|
||||||
private Button done, close;
|
|
||||||
private bool closing;
|
|
||||||
private Exception failure;
|
|
||||||
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
|
||||||
private readonly Thread thread;
|
|
||||||
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
|
||||||
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
|
||||||
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
|
||||||
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
|
||||||
public KSwitcherUpdateProgress() {
|
|
||||||
thread = new Thread(delegate() {
|
|
||||||
try {
|
|
||||||
Application.EnableVisualStyles();
|
|
||||||
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
|
||||||
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
|
||||||
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
|
||||||
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
|
||||||
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
|
||||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
|
||||||
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
|
||||||
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
|
||||||
};
|
|
||||||
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
|
||||||
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
|
||||||
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
|
||||||
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
|
||||||
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
|
||||||
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
|
||||||
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
|
||||||
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
|
||||||
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
|
||||||
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
|
||||||
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
|
||||||
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
|
||||||
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
|
||||||
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
|
||||||
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
|
||||||
bottom.Controls.AddRange(new Control[]{footer,done});
|
|
||||||
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
|
||||||
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
|
||||||
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
|
||||||
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
|
||||||
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
|
||||||
Application.Run(form);
|
|
||||||
} catch(Exception e) { failure = e; ready.Set(); }
|
|
||||||
});
|
|
||||||
thread.IsBackground = true;
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
|
||||||
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
|
||||||
}
|
|
||||||
public void Report(string title, string text, int percent) {
|
|
||||||
if (form == null || form.IsDisposed) return;
|
|
||||||
form.BeginInvoke((Action)delegate {
|
|
||||||
stage.Text = title; detail.Text = text;
|
|
||||||
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
|
||||||
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
|
||||||
int separator = text.IndexOf("% - ");
|
|
||||||
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
|
||||||
if(title.StartsWith("Downloading")) steps.Current=0;
|
|
||||||
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
|
||||||
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
|
||||||
else if(title.StartsWith("Restarting")) steps.Current=3;
|
|
||||||
progress.Invalidate(); steps.Invalidate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public void SavePreview(string path) {
|
|
||||||
form.Invoke((Action)delegate {
|
|
||||||
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
|
||||||
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
|
||||||
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
|
||||||
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
|
||||||
public void Finish(string title, string text, bool success) {
|
|
||||||
form.Invoke((Action)delegate {
|
|
||||||
closing = true;
|
|
||||||
stage.Text = title; detail.Text = text;
|
|
||||||
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
|
||||||
steps.Complete = success;
|
|
||||||
if(!success) {
|
|
||||||
detail.Visible = false; steps.Visible = false;
|
|
||||||
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
|
||||||
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
|
||||||
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
|
||||||
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
|
||||||
}
|
|
||||||
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
|
||||||
close.Enabled = true; done.Visible = true;
|
|
||||||
form.AcceptButton = done; form.CancelButton = done;
|
|
||||||
progress.Invalidate(); steps.Invalidate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public void WaitForClose() { thread.Join(); }
|
|
||||||
public void Dispose() {
|
|
||||||
if (form != null && !form.IsDisposed)
|
|
||||||
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
|
||||||
thread.Join(3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
}
|
|
||||||
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
|
||||||
}
|
|
||||||
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
|
||||||
if ($script:progressWindow) {
|
|
||||||
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
|
||||||
catch { Write-Warning 'Could not refresh update progress.' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
|
||||||
if ($script:progressWindow) {
|
|
||||||
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
|
||||||
catch { Write-Warning 'Could not display the update result.' }
|
|
||||||
}
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-StableVersion([string]$Tag) {
|
function Get-StableVersion([string]$Tag) {
|
||||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||||
@@ -202,7 +20,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
|||||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$request = [Net.HttpWebRequest]::Create($Url)
|
$request = [Net.HttpWebRequest]::Create($Url)
|
||||||
$request.AllowAutoRedirect = $false
|
$request.AllowAutoRedirect = $false
|
||||||
@@ -218,20 +36,11 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressL
|
|||||||
$buffer = New-Object byte[] 65536
|
$buffer = New-Object byte[] 65536
|
||||||
$total = 0L
|
$total = 0L
|
||||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||||
$lastProgress = [DateTime]::MinValue
|
|
||||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||||
$total += $count
|
$total += $count
|
||||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||||
$outputStream.Write($buffer,0,$count)
|
$outputStream.Write($buffer,0,$count)
|
||||||
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
|
||||||
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
|
||||||
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
|
||||||
Show-UpdateProgress $ProgressLabel $detail $percent
|
|
||||||
$lastProgress = [DateTime]::UtcNow
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
|
||||||
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
|
||||||
} finally {
|
} finally {
|
||||||
if ($outputStream) { $outputStream.Dispose() }
|
if ($outputStream) { $outputStream.Dispose() }
|
||||||
if ($inputStream) { $inputStream.Dispose() }
|
if ($inputStream) { $inputStream.Dispose() }
|
||||||
@@ -411,9 +220,8 @@ function Write-Result($Value) {
|
|||||||
if ($Library) { return }
|
if ($Library) { return }
|
||||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||||
$changed = [Collections.Generic.List[string]]::new()
|
$changed = [Collections.Generic.List[string]]::new()
|
||||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
||||||
try {
|
try {
|
||||||
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
|
||||||
$current = Get-StableVersion $CurrentVersion
|
$current = Get-StableVersion $CurrentVersion
|
||||||
if (!$current) { throw 'Invalid installed version.' }
|
if (!$current) { throw 'Invalid installed version.' }
|
||||||
if ($Mode -eq 'Install') {
|
if ($Mode -eq 'Install') {
|
||||||
@@ -434,35 +242,25 @@ try {
|
|||||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||||
Show-UpdateProgress 'Downloading update' $release.version 0
|
Receive-File $release.zip $archive 50MB
|
||||||
Receive-File $release.zip $archive 50MB 'Downloading update'
|
|
||||||
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
|
||||||
Receive-File $release.checksum $checksum 64KB
|
Receive-File $release.checksum $checksum 64KB
|
||||||
Assert-Checksum $archive $checksum
|
Assert-Checksum $archive $checksum
|
||||||
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
|
||||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||||
Write-Result @{state='prepared';version=$release.version}
|
Write-Result @{state='prepared';version=$release.version}
|
||||||
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
|
||||||
$ready.Set() | Out-Null
|
$ready.Set() | Out-Null
|
||||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||||
$parentExited = $true
|
$parentExited = $true
|
||||||
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
|
||||||
Install-Files $stage $InstallDirectory $backup $changed
|
Install-Files $stage $InstallDirectory $backup $changed
|
||||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||||
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
|
||||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||||
$success = $true
|
$success = $true
|
||||||
if ($script:progressWindow) {
|
|
||||||
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
$message = $_.Exception.Message
|
$message = $_.Exception.Message
|
||||||
if ($parentExited) {
|
if ($parentExited) {
|
||||||
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
|
||||||
try {
|
try {
|
||||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||||
Undo-Files $InstallDirectory $backup $changed
|
Undo-Files $InstallDirectory $backup $changed
|
||||||
@@ -475,13 +273,9 @@ try {
|
|||||||
}
|
}
|
||||||
Write-Result @{state='error';error=$message}
|
Write-Result @{state='error';error=$message}
|
||||||
if ($ready -and $ready.WaitOne(0)) {
|
if ($ready -and $ready.WaitOne(0)) {
|
||||||
if ($script:progressWindow) {
|
|
||||||
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
|
||||||
} else {
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
if ($ready) { $ready.Dispose() }
|
if ($ready) { $ready.Dispose() }
|
||||||
@@ -497,8 +291,4 @@ try {
|
|||||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($script:progressWindow) {
|
|
||||||
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
|
||||||
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-377
@@ -2,9 +2,6 @@
|
|||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include "diagnostics.h"
|
|
||||||
#include "paste_probe.h"
|
|
||||||
#include "screenshot_notifications.h"
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -14,17 +11,6 @@ namespace ks
|
|||||||
App *App::current = nullptr;
|
App *App::current = nullptr;
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
const char *transformCommandName(int command)
|
|
||||||
{
|
|
||||||
return command == Toggle ? "Convert / cycle" : command >= Direct ? "Convert / direct" :
|
|
||||||
command == Case ? "Change case" : command == Translit ? "Transliterate" :
|
|
||||||
command == Undo ? "Undo" : "Automatic / snippet";
|
|
||||||
}
|
|
||||||
ULONG_PTR snippetReplayMarker()
|
|
||||||
{
|
|
||||||
static const char marker{};
|
|
||||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
|
||||||
}
|
|
||||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||||
{
|
{
|
||||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||||
@@ -246,7 +232,6 @@ class Transaction
|
|||||||
} // namespace
|
} // namespace
|
||||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||||
{
|
{
|
||||||
updateNotifier = notifyUpdateAvailable;
|
|
||||||
current = this;
|
current = this;
|
||||||
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
||||||
std::filesystem::create_directories(data);
|
std::filesystem::create_directories(data);
|
||||||
@@ -261,13 +246,6 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||||
}
|
}
|
||||||
setLoggingEnabled(settings.writeLog);
|
setLoggingEnabled(settings.writeLog);
|
||||||
if (settings.autoCorrect)
|
|
||||||
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
settings.autoCorrect = false;
|
|
||||||
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
|
|
||||||
}
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = std::make_unique<History>(data / L"history");
|
history = std::make_unique<History>(data / L"history");
|
||||||
@@ -290,15 +268,8 @@ App::~App()
|
|||||||
std::lock_guard lock(jobsMutex);
|
std::lock_guard lock(jobsMutex);
|
||||||
stop = true;
|
stop = true;
|
||||||
jobs.clear();
|
jobs.clear();
|
||||||
pendingHistorySearch = {};
|
|
||||||
}
|
}
|
||||||
jobsReady.notify_all();
|
jobsReady.notify_all();
|
||||||
historySearchReady.notify_all();
|
|
||||||
historyOcrReady.notify_all();
|
|
||||||
if (historyOcrWorker.joinable())
|
|
||||||
historyOcrWorker.join();
|
|
||||||
if (historySearchWorker.joinable())
|
|
||||||
historySearchWorker.join();
|
|
||||||
if (worker.joinable())
|
if (worker.joinable())
|
||||||
worker.join();
|
worker.join();
|
||||||
RemoveClipboardFormatListener(window);
|
RemoveClipboardFormatListener(window);
|
||||||
@@ -319,83 +290,6 @@ void App::enqueue(std::function<void()> job)
|
|||||||
}
|
}
|
||||||
jobsReady.notify_one();
|
jobsReady.notify_one();
|
||||||
}
|
}
|
||||||
void App::enqueueHistorySearch(std::function<void()> job)
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard lock(jobsMutex);
|
|
||||||
if (stop) return;
|
|
||||||
// Only the latest query matters. Clipboard capture and OCR must not delay it.
|
|
||||||
pendingHistorySearch = std::move(job);
|
|
||||||
if (!historySearchWorker.joinable())
|
|
||||||
historySearchWorker = std::thread([this]
|
|
||||||
{
|
|
||||||
for (;;)
|
|
||||||
{
|
|
||||||
std::function<void()> searchJob;
|
|
||||||
{
|
|
||||||
std::unique_lock lock(jobsMutex);
|
|
||||||
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
|
|
||||||
if (stop) return;
|
|
||||||
searchJob = std::move(pendingHistorySearch);
|
|
||||||
pendingHistorySearch = {};
|
|
||||||
}
|
|
||||||
try { searchJob(); }
|
|
||||||
catch (const std::exception &e) { report(wide(e.what())); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
historySearchReady.notify_one();
|
|
||||||
}
|
|
||||||
void App::scheduleHistoryOcr(const Settings &config)
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard lock(jobsMutex);
|
|
||||||
if (stop || !history || !config.history) return;
|
|
||||||
historyOcrSettings = config;
|
|
||||||
historyOcrRequested = true;
|
|
||||||
if (!historyOcrWorker.joinable())
|
|
||||||
historyOcrWorker = std::thread([this]
|
|
||||||
{
|
|
||||||
for (;;)
|
|
||||||
{
|
|
||||||
Settings config;
|
|
||||||
{
|
|
||||||
std::unique_lock lock(jobsMutex);
|
|
||||||
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
|
|
||||||
if (stop) return;
|
|
||||||
historyOcrRequested = false;
|
|
||||||
config = historyOcrSettings;
|
|
||||||
}
|
|
||||||
for (const auto &entry : history->entries())
|
|
||||||
{
|
|
||||||
{ std::lock_guard lock(jobsMutex); if (stop) return; }
|
|
||||||
auto profile = ocrProfile(config);
|
|
||||||
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
auto text = recognizeText(history->load(entry.id), config);
|
|
||||||
std::lock_guard lock(jobsMutex);
|
|
||||||
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
|
|
||||||
if (history->saveOcr(entry.id, text, config))
|
|
||||||
PostMessageW(window, HistoryMessage, 0, 0);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
// Automatic OCR never changes the clipboard or opens error dialogs.
|
|
||||||
log(L"History OCR: " + wide(e.what()));
|
|
||||||
try {
|
|
||||||
std::lock_guard lock(jobsMutex);
|
|
||||||
if (!stop && ocrProfile(historyOcrSettings) == profile)
|
|
||||||
history->saveOcr(entry.id, std::nullopt, config);
|
|
||||||
}
|
|
||||||
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
historyOcrReady.notify_one();
|
|
||||||
}
|
|
||||||
void App::report(const std::wstring &text)
|
void App::report(const std::wstring &text)
|
||||||
{
|
{
|
||||||
log(text);
|
log(text);
|
||||||
@@ -437,12 +331,11 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
void App::applySettings(const Settings &s)
|
void App::applySettings(const Settings &s)
|
||||||
{
|
{
|
||||||
bool autostartChanged = false;
|
bool autostartChanged = false;
|
||||||
AutoCorrector predictor;
|
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
validateTesseract(s);
|
|
||||||
if (s.autoCorrect)
|
if (s.autoCorrect)
|
||||||
{
|
{
|
||||||
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
AutoCorrector probe;
|
||||||
|
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||||
}
|
}
|
||||||
if (!registerHotkeys(s))
|
if (!registerHotkeys(s))
|
||||||
{
|
{
|
||||||
@@ -474,7 +367,6 @@ void App::applySettings(const Settings &s)
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
settings = s;
|
settings = s;
|
||||||
autoPredictor = std::move(predictor);
|
|
||||||
setLoggingEnabled(s.writeLog);
|
setLoggingEnabled(s.writeLog);
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
@@ -482,7 +374,6 @@ void App::applySettings(const Settings &s)
|
|||||||
}
|
}
|
||||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
scheduleHistoryOcr(s);
|
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
}
|
}
|
||||||
@@ -538,7 +429,6 @@ void App::resetInput()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
|
||||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||||
{
|
{
|
||||||
if (ownKeyboardInput(k))
|
if (ownKeyboardInput(k))
|
||||||
@@ -579,33 +469,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.record(ch, fg, layout, GetTickCount64());
|
run.record(ch, fg, layout, GetTickCount64());
|
||||||
if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() &&
|
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||||
mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
|
||||||
run.keys.size() <= 41 && !busy && !run.overflow)
|
run.keys.size() <= 41 && !busy && !run.overflow)
|
||||||
{
|
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
|
||||||
for (int source = 0; source < (int)layouts.size(); ++source)
|
|
||||||
if (layouts[source].handle == layout)
|
|
||||||
{
|
|
||||||
std::wstring predicted;
|
|
||||||
for (wchar_t key : run.keys)
|
|
||||||
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
|
|
||||||
if (autoPredictor.suggest(predicted, source, layouts, settings))
|
|
||||||
{
|
|
||||||
// Reserve the transaction before the next physical key arrives.
|
|
||||||
// The delimiter itself passes through; subsequent input is queued.
|
|
||||||
typingTarget = fg;
|
|
||||||
autoPending = true;
|
|
||||||
busy = true;
|
|
||||||
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
|
|
||||||
{
|
|
||||||
typingTarget = nullptr;
|
|
||||||
autoPending = false;
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (busy || settings.snippets.empty())
|
if (busy || settings.snippets.empty())
|
||||||
return;
|
return;
|
||||||
auto keys = lower(run.keys);
|
auto keys = lower(run.keys);
|
||||||
@@ -633,18 +499,7 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
for (auto &trigger : variants)
|
for (auto &trigger : variants)
|
||||||
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
||||||
{
|
{
|
||||||
// Keep subsequent typing out of the selection/paste transaction.
|
PostMessageW(window, SnippetMessage, (WPARAM)i, 0);
|
||||||
typingTarget = fg;
|
|
||||||
snippetPending = (int)i;
|
|
||||||
snippetTyping = true;
|
|
||||||
busy = true;
|
|
||||||
if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load()))
|
|
||||||
{
|
|
||||||
typingTarget = nullptr;
|
|
||||||
snippetPending = -1;
|
|
||||||
snippetTyping = false;
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,27 +510,6 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
// down AND up events use exactly the same path as physical keyboard input.
|
// down AND up events use exactly the same path as physical keyboard input.
|
||||||
if (ownKeyboardInput(key) || installingUpdate)
|
if (ownKeyboardInput(key) || installingUpdate)
|
||||||
return false;
|
return false;
|
||||||
{
|
|
||||||
std::lock_guard lock(stateMutex);
|
|
||||||
if (typingTarget && GetForegroundWindow() == typingTarget)
|
|
||||||
{
|
|
||||||
INPUT input{};
|
|
||||||
input.type = INPUT_KEYBOARD;
|
|
||||||
input.ki.wVk = static_cast<WORD>(key.vkCode);
|
|
||||||
input.ki.wScan = static_cast<WORD>(key.scanCode);
|
|
||||||
input.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) |
|
|
||||||
((key.flags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0);
|
|
||||||
if (key.vkCode == VK_PACKET)
|
|
||||||
{
|
|
||||||
input.ki.wVk = 0;
|
|
||||||
input.ki.dwFlags |= KEYEVENTF_UNICODE;
|
|
||||||
}
|
|
||||||
// Replay as ordinary input so tracking and shortcuts see it once,
|
|
||||||
// after the replacement and layout switch have finished.
|
|
||||||
deferredTyping.push_back(input);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DWORD captureProcess{};
|
DWORD captureProcess{};
|
||||||
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
||||||
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
||||||
@@ -718,48 +552,6 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(stateMutex);
|
|
||||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
|
||||||
return false;
|
|
||||||
typingTarget = target;
|
|
||||||
snippetTyping = snippet;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
void App::replayTyping()
|
|
||||||
{
|
|
||||||
std::vector<INPUT> inputs;
|
|
||||||
HWND target;
|
|
||||||
{
|
|
||||||
std::lock_guard lock(stateMutex);
|
|
||||||
target = typingTarget;
|
|
||||||
typingTarget = nullptr;
|
|
||||||
autoPending = false;
|
|
||||||
snippetPending = -1;
|
|
||||||
inputs.swap(deferredTyping);
|
|
||||||
if (snippetTyping)
|
|
||||||
for (auto &input : inputs)
|
|
||||||
input.ki.dwExtraInfo = snippetReplayMarker();
|
|
||||||
snippetTyping = false;
|
|
||||||
}
|
|
||||||
busy = false;
|
|
||||||
if (inputs.empty())
|
|
||||||
return;
|
|
||||||
if (GetForegroundWindow() != target)
|
|
||||||
{
|
|
||||||
resetInput();
|
|
||||||
report(L"Typing during correction was not replayed because the active window changed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Runs on the hook thread: no physical key can pass between releasing the
|
|
||||||
// gate and submitting the entire ordered batch to the Windows input queue.
|
|
||||||
if (SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) != inputs.size())
|
|
||||||
{
|
|
||||||
resetInput();
|
|
||||||
report(L"Windows could not replay all typing buffered during correction.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (code == HC_ACTION && current &&
|
if (code == HC_ACTION && current &&
|
||||||
@@ -795,10 +587,9 @@ void App::dispatch(int command)
|
|||||||
return;
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (command == 23) { checkUpdate(true); return; }
|
|
||||||
if (command == Screenshot)
|
if (command == Screenshot)
|
||||||
{
|
{
|
||||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
if (!busy) showScreenshot(*this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command == ShowHistory)
|
if (command == ShowHistory)
|
||||||
@@ -857,30 +648,12 @@ void App::transform(int command, int snippet)
|
|||||||
L" version=" + std::to_wstring(currentVersion) +
|
L" version=" + std::to_wstring(currentVersion) +
|
||||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||||
}
|
}
|
||||||
const bool preDeferred = (command == AutoCorrect && autoPending) ||
|
if (busy.exchange(true))
|
||||||
(snippet >= 0 && snippetPending == snippet);
|
|
||||||
if (busy.exchange(true) && !preDeferred)
|
|
||||||
{
|
{
|
||||||
if (manual)
|
if (manual)
|
||||||
{
|
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
||||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
|
||||||
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
|
|
||||||
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
|
|
||||||
{"elapsedMs", GetTickCount64() - requestedAt}};
|
|
||||||
diagnosticOperation(record.dump());
|
|
||||||
}
|
|
||||||
catch (...) {}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (preDeferred)
|
|
||||||
{
|
|
||||||
autoPending = false;
|
|
||||||
snippetPending = -1;
|
|
||||||
}
|
|
||||||
Run recent;
|
Run recent;
|
||||||
std::optional<LastTransform> previous;
|
std::optional<LastTransform> previous;
|
||||||
Settings config;
|
Settings config;
|
||||||
@@ -895,24 +668,19 @@ void App::transform(int command, int snippet)
|
|||||||
version = inputVersion;
|
version = inputVersion;
|
||||||
}
|
}
|
||||||
enqueue(
|
enqueue(
|
||||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
|
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
||||||
{
|
{
|
||||||
struct BusyReset
|
struct BusyReset
|
||||||
{
|
{
|
||||||
std::atomic<bool> &b;
|
std::atomic<bool> &b;
|
||||||
HWND window;
|
|
||||||
bool deferred{};
|
|
||||||
~BusyReset()
|
~BusyReset()
|
||||||
{
|
{
|
||||||
if (deferred)
|
|
||||||
PostMessageW(window, ReplayTypingMessage, 0, 0);
|
|
||||||
else
|
|
||||||
b = false;
|
b = false;
|
||||||
}
|
}
|
||||||
} reset{busy, window, preDeferred};
|
} reset{busy};
|
||||||
auto start = GetTickCount64();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
std::wstring text, result, expected;
|
||||||
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||||
if (manual)
|
if (manual)
|
||||||
{
|
{
|
||||||
@@ -937,34 +705,21 @@ void App::transform(int command, int snippet)
|
|||||||
bool enabled;
|
bool enabled;
|
||||||
std::wstring &reason;
|
std::wstring &reason;
|
||||||
std::wstring &text, &result, &expected;
|
std::wstring &text, &result, &expected;
|
||||||
std::wstring &actual, &verification;
|
|
||||||
int &hint, &to;
|
int &hint, &to;
|
||||||
int command;
|
|
||||||
const std::vector<Layout> &layouts;
|
|
||||||
~Audit()
|
~Audit()
|
||||||
{
|
{
|
||||||
if (enabled)
|
if (enabled)
|
||||||
try
|
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
|
||||||
{
|
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
|
||||||
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
|
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
|
||||||
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
|
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
|
||||||
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
|
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
|
||||||
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
|
L" result=" + diagnosticText(result),
|
||||||
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
||||||
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
|
reason != L"unchanged-expected");
|
||||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
|
||||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
|
||||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
|
||||||
{"expected", bounded(result)}, {"actual", bounded(actual)},
|
|
||||||
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
|
|
||||||
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
|
|
||||||
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
|
|
||||||
}
|
}
|
||||||
catch (...) {} // Logging must never abort a completed input transaction.
|
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
|
||||||
}
|
|
||||||
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
|
|
||||||
actual, verification, hint, to, command, available};
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto valid = [&]
|
auto valid = [&]
|
||||||
@@ -1000,27 +755,11 @@ void App::transform(int command, int snippet)
|
|||||||
std::wstring predicted;
|
std::wstring predicted;
|
||||||
for (wchar_t key : recent.keys)
|
for (wchar_t key : recent.keys)
|
||||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||||
expected = predicted;
|
|
||||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||||
!autoCorrectionAllowed(target, config) || !valid())
|
!autoCorrectionAllowed(target, config) || !valid())
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reason = L"clipboard-snapshot";
|
reason = L"clipboard-snapshot";
|
||||||
if (command == AutoCorrect || snippet >= 0)
|
|
||||||
{
|
|
||||||
if (!beginTypingDeferral(target, version, snippet >= 0))
|
|
||||||
return;
|
|
||||||
reset.deferred = true;
|
|
||||||
}
|
|
||||||
// Collapse a temporary selection on every failed auto-correction,
|
|
||||||
// including clipboard exceptions, before queued typing is replayed.
|
|
||||||
bool autoSelected = false;
|
|
||||||
struct SelectionReset
|
|
||||||
{
|
|
||||||
bool &selected;
|
|
||||||
std::function<bool()> valid;
|
|
||||||
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
|
|
||||||
} selectionReset{autoSelected, valid};
|
|
||||||
Transaction tx(*this, target, version);
|
Transaction tx(*this, target, version);
|
||||||
reason = L"selection-unavailable";
|
reason = L"selection-unavailable";
|
||||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||||
@@ -1037,15 +776,11 @@ void App::transform(int command, int snippet)
|
|||||||
// conversion, automatic correction must never fall back to a suffix.
|
// conversion, automatic correction must never fall back to a suffix.
|
||||||
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||||
return;
|
return;
|
||||||
autoSelected = true;
|
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
// Editors may split hj,bnm at the comma. Extend only a verified
|
// Some editors first select only ") ". Extend by whole-word
|
||||||
// suffix of the tracked input, and still require a complete match.
|
// boundaries, then still verify against the complete tracked run.
|
||||||
for (int i = 0; i < 3 && tx.valid() &&
|
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
||||||
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
|
|
||||||
(autoClosingSelection(text, config) ||
|
|
||||||
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
|
|
||||||
{
|
{
|
||||||
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||||
break;
|
break;
|
||||||
@@ -1057,10 +792,7 @@ void App::transform(int command, int snippet)
|
|||||||
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
||||||
{
|
{
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
{
|
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
autoSelected = false;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
to = suggestion->target;
|
to = suggestion->target;
|
||||||
@@ -1103,11 +835,13 @@ void App::transform(int command, int snippet)
|
|||||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||||
if (!tx.select(sn.trigger.size()))
|
if (!tx.select(sn.trigger.size()))
|
||||||
return;
|
return;
|
||||||
autoSelected = true;
|
|
||||||
reason = L"snippet-verification-mismatch";
|
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
if (tail.match(text, available, physicalLayout()) < 0)
|
if (tail.match(text, available, physicalLayout()) < 0)
|
||||||
|
{
|
||||||
|
if (tx.valid())
|
||||||
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
result = parseTemplate(sn.text).text;
|
result = parseTemplate(sn.text).text;
|
||||||
automatic = true;
|
automatic = true;
|
||||||
}
|
}
|
||||||
@@ -1189,38 +923,18 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool sent = false;
|
bool sent = false;
|
||||||
PasteProbe probe(config.writeLog && manual && result != text, text);
|
|
||||||
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||||
L" changed=" + std::to_wstring(result != text));
|
L" changed=" + std::to_wstring(result != text));
|
||||||
if (result != text)
|
if (result != text)
|
||||||
sent = tx.paste(textClip(result));
|
sent = tx.paste(textClip(result));
|
||||||
else if (automatic && tx.valid())
|
else if (automatic && tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
if (sent || result == text)
|
|
||||||
autoSelected = false;
|
|
||||||
reason = result == text ? L"conversion-unchanged" :
|
reason = result == text ? L"conversion-unchanged" :
|
||||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||||
if (manual && result == text)
|
if (result == text &&
|
||||||
{
|
(std::none_of(text.begin(), text.end(), letter) ||
|
||||||
reason = L"Conversion unchanged";
|
(command >= Direct && detect(text, available, hint) == to)))
|
||||||
verification = L"calculated-result-equals-original";
|
reason = L"unchanged-expected";
|
||||||
}
|
|
||||||
if (sent && manual && config.writeLog)
|
|
||||||
{
|
|
||||||
verification = probe.status;
|
|
||||||
if (tx.valid())
|
|
||||||
{
|
|
||||||
auto observed = probe.read((int)caretSteps(result));
|
|
||||||
verification = probe.status;
|
|
||||||
if (observed && tx.valid())
|
|
||||||
{
|
|
||||||
actual = *observed;
|
|
||||||
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
|
||||||
}
|
|
||||||
else reason = L"Paste not verified";
|
|
||||||
}
|
|
||||||
else verification = L"context-changed-after-paste";
|
|
||||||
}
|
|
||||||
if (sent && !tx.valid())
|
if (sent && !tx.valid())
|
||||||
reason = L"context-changed-after-paste";
|
reason = L"context-changed-after-paste";
|
||||||
if (sent && command == Undo && previous && previous->autoCorrected)
|
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||||
@@ -1238,22 +952,12 @@ void App::transform(int command, int snippet)
|
|||||||
if (snippet >= 0 && sent && tx.valid())
|
if (snippet >= 0 && sent && tx.valid())
|
||||||
{
|
{
|
||||||
auto t = parseTemplate(config.snippets[snippet].text);
|
auto t = parseTemplate(config.snippets[snippet].text);
|
||||||
if (!t.suffix.empty())
|
if (!t.suffix.empty() && tx.select(caretSteps(t.suffix)))
|
||||||
{
|
{
|
||||||
if (!tx.select(caretSteps(t.suffix))) return;
|
|
||||||
auto suffix = tx.copy();
|
auto suffix = tx.copy();
|
||||||
if (!tx.valid()) return;
|
if (tx.valid())
|
||||||
if (newlines(suffix) != newlines(t.suffix))
|
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
||||||
{
|
|
||||||
chord(VK_RIGHT);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!chord(VK_LEFT)) return;
|
|
||||||
}
|
|
||||||
// Action markers run after insertion and cursor placement,
|
|
||||||
// before any physical typing buffered during expansion.
|
|
||||||
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
|
||||||
if (!chord(VK_RETURN)) break;
|
|
||||||
}
|
}
|
||||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||||
{
|
{
|
||||||
@@ -1346,7 +1050,6 @@ void App::captureHistory()
|
|||||||
!f.data[1] && !f.data[2] && !f.data[3])
|
!f.data[1] && !f.data[2] && !f.data[3])
|
||||||
return;
|
return;
|
||||||
history->add(clip, app, config);
|
history->add(clip, app, config);
|
||||||
scheduleHistoryOcr(config);
|
|
||||||
PostMessageW(window, HistoryMessage, 0, 0);
|
PostMessageW(window, HistoryMessage, 0, 0);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -1374,9 +1077,7 @@ void App::recognizeEntry(std::wstring id)
|
|||||||
} reset{busy};
|
} reset{busy};
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto cached = history->ocrText(id, ocrProfile(config));
|
auto text = recognizeText(history->load(id));
|
||||||
auto text = cached ? *cached : recognizeText(history->load(id), config);
|
|
||||||
if (!cached) history->saveOcr(id, text, config);
|
|
||||||
if (text.empty())
|
if (text.empty())
|
||||||
{
|
{
|
||||||
report(L"No text was found in the image.");
|
report(L"No text was found in the image.");
|
||||||
@@ -1512,7 +1213,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
|
|||||||
updateWorker = std::thread([this, interactive, install, version]
|
updateWorker = std::thread([this, interactive, install, version]
|
||||||
{
|
{
|
||||||
auto result = std::make_unique<UpdateResult>();
|
auto result = std::make_unique<UpdateResult>();
|
||||||
try { *result = runUpdate(install, version, interactive); }
|
try { *result = runUpdate(install, version); }
|
||||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||||
result->interactive = interactive;
|
result->interactive = interactive;
|
||||||
result->install = install;
|
result->install = install;
|
||||||
@@ -1537,8 +1238,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (app->updateWorker.joinable()) app->updateWorker.join();
|
if (app->updateWorker.joinable()) app->updateWorker.join();
|
||||||
app->updateRunning = false;
|
app->updateRunning = false;
|
||||||
app->installingUpdate = false;
|
app->installingUpdate = false;
|
||||||
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
|
|
||||||
(result->error.empty() ? L"" : L"; " + result->error));
|
|
||||||
if (result->state == L"prepared")
|
if (result->state == L"prepared")
|
||||||
{
|
{
|
||||||
// The helper has verified and staged the package; it waits for process exit.
|
// The helper has verified and staged the package; it waits for process exit.
|
||||||
@@ -1554,18 +1253,14 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
||||||
app->checkUpdate(true, true, result->version);
|
app->checkUpdate(true, true, result->version);
|
||||||
}
|
}
|
||||||
else if (app->notifiedUpdate != result->version)
|
|
||||||
{
|
|
||||||
if (app->updateNotifier(result->version))
|
|
||||||
{
|
|
||||||
app->notifiedUpdate = result->version;
|
|
||||||
log(L"Update notification submitted to Windows: " + result->version);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
|
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||||
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (result->state == L"current")
|
else if (result->state == L"current")
|
||||||
@@ -1578,15 +1273,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
else if (result->interactive)
|
else if (result->interactive)
|
||||||
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
||||||
else
|
else
|
||||||
diagnosticEvent(L"Background update check failed: " + result->error);
|
diagnosticEvent(L"Startup update check failed: " + result->error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_HOTKEY:
|
case WM_HOTKEY:
|
||||||
app->dispatch((int)wp);
|
app->dispatch((int)wp);
|
||||||
return 0;
|
return 0;
|
||||||
case ReplayTypingMessage:
|
|
||||||
app->replayTyping();
|
|
||||||
return 0;
|
|
||||||
case TrayMessage:
|
case TrayMessage:
|
||||||
if (LOWORD(lp) == WM_CONTEXTMENU)
|
if (LOWORD(lp) == WM_CONTEXTMENU)
|
||||||
app->trayMenu();
|
app->trayMenu();
|
||||||
@@ -1601,22 +1293,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->captureHistory();
|
app->captureHistory();
|
||||||
return 0;
|
return 0;
|
||||||
case AutoCorrectMessage:
|
case AutoCorrectMessage:
|
||||||
if (app->autoPending)
|
if (app->installingUpdate) return 0;
|
||||||
{
|
app->pendingAutoVersion = (uint64_t)wp;
|
||||||
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
SetTimer(hwnd, 3, 250, nullptr);
|
||||||
app->transform(AutoCorrect);
|
|
||||||
else
|
|
||||||
app->replayTyping();
|
|
||||||
}
|
|
||||||
return 0;
|
return 0;
|
||||||
case SnippetMessage:
|
case SnippetMessage:
|
||||||
if (app->snippetPending == (int)wp)
|
if (app->installingUpdate) return 0;
|
||||||
{
|
|
||||||
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
|
|
||||||
app->transform(0, (int)wp);
|
app->transform(0, (int)wp);
|
||||||
else
|
|
||||||
app->replayTyping();
|
|
||||||
}
|
|
||||||
return 0;
|
return 0;
|
||||||
case HistoryMessage:
|
case HistoryMessage:
|
||||||
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
||||||
@@ -1638,7 +1321,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COPYDATA:
|
case WM_COPYDATA:
|
||||||
{
|
{
|
||||||
auto *data = (COPYDATASTRUCT *)lp;
|
auto *data = (COPYDATASTRUCT *)lp;
|
||||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
|
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
||||||
{
|
{
|
||||||
if (app->installingUpdate) return FALSE;
|
if (app->installingUpdate) return FALSE;
|
||||||
return configurationRequest(*app, (HWND)wp, *data);
|
return configurationRequest(*app, (HWND)wp, *data);
|
||||||
@@ -1654,9 +1337,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
if (wp == 5)
|
if (wp == 5)
|
||||||
{
|
{
|
||||||
// After the initial 5-second check, repeat while the resident stays open.
|
KillTimer(hwnd, 5);
|
||||||
// Rearm even when a check is already running or the previous one failed.
|
|
||||||
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
|
|
||||||
app->checkUpdate(false);
|
app->checkUpdate(false);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1668,6 +1349,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->dispatch(20);
|
app->dispatch(20);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (wp == 3)
|
||||||
|
{
|
||||||
|
KillTimer(hwnd, 3);
|
||||||
|
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||||
|
app->transform(AutoCorrect);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (wp == 2)
|
if (wp == 2)
|
||||||
{
|
{
|
||||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||||
@@ -1750,8 +1438,6 @@ int App::execute()
|
|||||||
if (!keyboardHook || !mouseHook)
|
if (!keyboardHook || !mouseHook)
|
||||||
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
||||||
AddClipboardFormatListener(window);
|
AddClipboardFormatListener(window);
|
||||||
// Resume pending OCR, including older images without a saved OCR state.
|
|
||||||
scheduleHistoryOcr(settings);
|
|
||||||
addTray();
|
addTray();
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
@@ -1762,13 +1448,10 @@ int App::execute()
|
|||||||
SetTimer(window, 2, 10000, nullptr);
|
SetTimer(window, 2, 10000, nullptr);
|
||||||
else
|
else
|
||||||
SetTimer(window, 5, 5000, nullptr);
|
SetTimer(window, 5, 5000, nullptr);
|
||||||
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
if (keyboardHook && mouseHook)
|
||||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
confirmUpdatedStartup(updateStartupEvent);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
try { registerScreenshotNotifications(); }
|
|
||||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
|
||||||
auto screenshotActivation=startScreenshotActivationListener();
|
|
||||||
MSG msg;
|
MSG msg;
|
||||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
@@ -1787,7 +1470,6 @@ int App::execute()
|
|||||||
TranslateMessage(&msg);
|
TranslateMessage(&msg);
|
||||||
DispatchMessageW(&msg);
|
DispatchMessageW(&msg);
|
||||||
}
|
}
|
||||||
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
|
|
||||||
return (int)msg.wParam;
|
return (int)msg.wParam;
|
||||||
}
|
}
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, Histo
|
|||||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||||
Screenshot = 7, Direct = 100;
|
Screenshot = 7, Direct = 100;
|
||||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||||
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
|
|
||||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||||
struct LastTransform
|
struct LastTransform
|
||||||
{
|
{
|
||||||
@@ -38,7 +37,6 @@ class App
|
|||||||
Settings settings;
|
Settings settings;
|
||||||
std::unique_ptr<History> history;
|
std::unique_ptr<History> history;
|
||||||
AutoCorrector autoCorrector;
|
AutoCorrector autoCorrector;
|
||||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
|
||||||
std::atomic<bool> busy{false}, suspended{false};
|
std::atomic<bool> busy{false}, suspended{false};
|
||||||
std::atomic<DWORD> ownSequence{0};
|
std::atomic<DWORD> ownSequence{0};
|
||||||
std::atomic<uint64_t> inputVersion{0};
|
std::atomic<uint64_t> inputVersion{0};
|
||||||
@@ -63,7 +61,6 @@ class App
|
|||||||
void recognizeEntry(std::wstring);
|
void recognizeEntry(std::wstring);
|
||||||
void captureHistory();
|
void captureHistory();
|
||||||
void enqueue(std::function<void()>);
|
void enqueue(std::function<void()>);
|
||||||
void enqueueHistorySearch(std::function<void()>);
|
|
||||||
void report(const std::wstring &);
|
void report(const std::wstring &);
|
||||||
void trayMenu();
|
void trayMenu();
|
||||||
void addTray();
|
void addTray();
|
||||||
@@ -71,7 +68,6 @@ class App
|
|||||||
void applySettings(const Settings &);
|
void applySettings(const Settings &);
|
||||||
void reloadLayouts();
|
void reloadLayouts();
|
||||||
void clearHistory();
|
void clearHistory();
|
||||||
void clearLog(bool previous);
|
|
||||||
void setHotkeyCapture(HWND);
|
void setHotkeyCapture(HWND);
|
||||||
bool isHotkey(DWORD, UINT);
|
bool isHotkey(DWORD, UINT);
|
||||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
@@ -87,39 +83,24 @@ class App
|
|||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
std::deque<std::function<void()>> jobs;
|
std::deque<std::function<void()>> jobs;
|
||||||
std::thread worker;
|
std::thread worker;
|
||||||
std::thread historySearchWorker;
|
|
||||||
std::condition_variable historySearchReady;
|
|
||||||
std::function<void()> pendingHistorySearch;
|
|
||||||
std::thread historyOcrWorker;
|
|
||||||
std::condition_variable historyOcrReady;
|
|
||||||
bool historyOcrRequested{};
|
|
||||||
Settings historyOcrSettings;
|
|
||||||
void scheduleHistoryOcr(const Settings &);
|
|
||||||
std::thread updateWorker;
|
std::thread updateWorker;
|
||||||
bool updateRunning{}, installingUpdate{};
|
bool updateRunning{}, installingUpdate{};
|
||||||
std::wstring availableUpdate, notifiedUpdate;
|
std::wstring availableUpdate;
|
||||||
bool (*updateNotifier)(const std::wstring &){};
|
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
|
uint64_t pendingAutoVersion{};
|
||||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||||
ULONGLONG previousAttemptAt{};
|
ULONGLONG previousAttemptAt{};
|
||||||
HWND previousAttemptWindow{};
|
HWND previousAttemptWindow{};
|
||||||
int previousAttemptCommand{}, repeatAttempts{};
|
int previousAttemptCommand{}, repeatAttempts{};
|
||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
HWND typingTarget{};
|
|
||||||
bool autoPending{};
|
|
||||||
int snippetPending = -1;
|
|
||||||
bool snippetTyping{};
|
|
||||||
std::vector<INPUT> deferredTyping;
|
|
||||||
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
|
||||||
void replayTyping();
|
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||||
};
|
};
|
||||||
void showHistory(App &, bool visible = true);
|
void showHistory(App &);
|
||||||
void launchScreenshot(App &);
|
void showScreenshot(App &);
|
||||||
void refreshHistory(App &);
|
void refreshHistory(App &);
|
||||||
void showSettings(App &, bool visible = true);
|
void showSettings(App &, bool visible = true);
|
||||||
void showLogs(App &);
|
void showLogs(App &);
|
||||||
|
|||||||
+9
-36
@@ -70,18 +70,9 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
|||||||
}
|
}
|
||||||
if (offsets.empty())
|
if (offsets.empty())
|
||||||
throw std::runtime_error("Empty auto-correction dictionary");
|
throw std::runtime_error("Empty auto-correction dictionary");
|
||||||
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
for (size_t i = 1; i < offsets.size(); ++i)
|
||||||
auto frequency = [&](uint32_t offset)
|
if (word(i - 1) >= word(i))
|
||||||
{
|
throw std::runtime_error("Dictionary must be uniquely sorted");
|
||||||
int rank{};
|
|
||||||
auto start = offset + entry(offset).size() + 1;
|
|
||||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
|
||||||
return rank;
|
|
||||||
};
|
|
||||||
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
|
||||||
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
|
|
||||||
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
|
||||||
{ return entry(a) == entry(b); }), offsets.end());
|
|
||||||
offsets.shrink_to_fit();
|
offsets.shrink_to_fit();
|
||||||
bytes.shrink_to_fit();
|
bytes.shrink_to_fit();
|
||||||
}
|
}
|
||||||
@@ -152,11 +143,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
suffix.insert(suffix.begin(), word.back());
|
suffix.insert(suffix.begin(), word.back());
|
||||||
word.pop_back();
|
word.pop_back();
|
||||||
}
|
}
|
||||||
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
||||||
// Validate letters and capitalization after conversion, not before it.
|
|
||||||
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
|
||||||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
|
||||||
suffix.size() > 6 || rejected.contains(lower(word)))
|
|
||||||
return {};
|
return {};
|
||||||
for (auto &never : settings.autoNever)
|
for (auto &never : settings.autoNever)
|
||||||
if (lower(never) == lower(word))
|
if (lower(never) == lower(word))
|
||||||
@@ -164,24 +151,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||||
if (!dictionaries.contains(sourceLanguage))
|
if (!dictionaries.contains(sourceLanguage))
|
||||||
return {};
|
return {};
|
||||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
|
||||||
{
|
|
||||||
auto found = settings.autoWords.find(language);
|
|
||||||
return found != settings.autoWords.end() &&
|
|
||||||
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
|
|
||||||
{ return lower(known) == lower(candidate); });
|
|
||||||
};
|
|
||||||
// Valid words from either selected language are never changed.
|
// Valid words from either selected language are never changed.
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
if (d.rank(word) || personal(language, word))
|
if (d.rank(word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
std::optional<std::wstring> matchedText;
|
int matches = 0;
|
||||||
auto priority = [&](int layout)
|
|
||||||
{
|
|
||||||
auto language = layoutLanguage(layouts[layout]);
|
|
||||||
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
|
||||||
};
|
|
||||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||||
{
|
{
|
||||||
if (i == source || !layouts[i].convertible ||
|
if (i == source || !layouts[i].convertible ||
|
||||||
@@ -193,14 +168,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||||
continue;
|
continue;
|
||||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
int rank = dictionary->second.rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (matchedText && *matchedText != converted)
|
if (++matches > 1)
|
||||||
return {};
|
return {};
|
||||||
matchedText = converted;
|
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
||||||
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
|
||||||
(!candidate || priority(i) < priority(candidate->target)))
|
|
||||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
|
|||||||
+6
-12
@@ -57,7 +57,6 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (wp == 21) showSnippets(*app);
|
if (wp == 21) showSnippets(*app);
|
||||||
else if (wp == 22) showLogs(*app);
|
|
||||||
else showSettings(*app);
|
else showSettings(*app);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -70,7 +69,6 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||||
if (app->logWindow) DestroyWindow(app->logWindow);
|
|
||||||
PostQuitMessage(0);
|
PostQuitMessage(0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -88,7 +86,6 @@ App::~App()
|
|||||||
{
|
{
|
||||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||||
if (logWindow) DestroyWindow(logWindow);
|
|
||||||
if (window) DestroyWindow(window);
|
if (window) DestroyWindow(window);
|
||||||
current = nullptr;
|
current = nullptr;
|
||||||
}
|
}
|
||||||
@@ -123,9 +120,9 @@ void App::setHotkeyCapture(HWND field)
|
|||||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
void showLogs(App &app) { request(app, ConfigLogs); }
|
||||||
|
|
||||||
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
int runConfigurator(HINSTANCE instance, bool snippets)
|
||||||
{
|
{
|
||||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||||
@@ -143,7 +140,7 @@ int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
|||||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||||
AllowSetForegroundWindow(pid);
|
AllowSetForegroundWindow(pid);
|
||||||
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
@@ -172,10 +169,9 @@ int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
|||||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||||
setLoggingEnabled(app.settings.writeLog);
|
if (snippets) showSnippets(app); else showSettings(app);
|
||||||
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
|
||||||
MSG message;
|
MSG message;
|
||||||
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||||
{
|
{
|
||||||
@@ -184,7 +180,6 @@ int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
|||||||
}
|
}
|
||||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||||
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -198,9 +193,8 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
{
|
{
|
||||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||||
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
|
||||||
LocalFree(args);
|
LocalFree(args);
|
||||||
result = ks::runConfigurator(instance, snippets, logs);
|
result = ks::runConfigurator(instance, snippets);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
|||||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||||
}
|
}
|
||||||
void launchConfiguration(App &app, bool snippets, bool logs)
|
void launchConfiguration(App &app, bool snippets)
|
||||||
{
|
{
|
||||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||||
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||||
@@ -80,11 +80,6 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
case ConfigLogs:
|
case ConfigLogs:
|
||||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||||
break;
|
break;
|
||||||
case ConfigClearLog:
|
|
||||||
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
|
||||||
throw std::runtime_error("Invalid log selection.");
|
|
||||||
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
|
||||||
break;
|
|
||||||
default: return FALSE;
|
default: return FALSE;
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ namespace ks
|
|||||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
|
ConfigReply = ConfigRead + 6;
|
||||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
bool configurationPeer(HWND, const wchar_t *filename);
|
||||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
void launchConfiguration(App &, bool snippets = false);
|
||||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-11
@@ -261,17 +261,7 @@ Template parseTemplate(const std::wstring &text)
|
|||||||
size_t cursor = std::wstring::npos;
|
size_t cursor = std::wstring::npos;
|
||||||
for (size_t i = 0; i < text.size();)
|
for (size_t i = 0; i < text.size();)
|
||||||
{
|
{
|
||||||
if (text.compare(i, 9, L"{{enter}}") == 0)
|
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||||
{
|
|
||||||
t.text += L"{enter}";
|
|
||||||
i += 9;
|
|
||||||
}
|
|
||||||
else if (text.compare(i, 7, L"{enter}") == 0)
|
|
||||||
{
|
|
||||||
++t.enters;
|
|
||||||
i += 7;
|
|
||||||
}
|
|
||||||
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
|
||||||
{
|
{
|
||||||
t.text += L"{cursor}";
|
t.text += L"{cursor}";
|
||||||
i += 10;
|
i += 10;
|
||||||
|
|||||||
+1
-8
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -34,7 +33,6 @@ struct Template
|
|||||||
{
|
{
|
||||||
std::wstring text, suffix;
|
std::wstring text, suffix;
|
||||||
int cursors = 0;
|
int cursors = 0;
|
||||||
int enters = 0;
|
|
||||||
};
|
};
|
||||||
Template parseTemplate(const std::wstring &);
|
Template parseTemplate(const std::wstring &);
|
||||||
struct Hotkey
|
struct Hotkey
|
||||||
@@ -50,23 +48,18 @@ struct Settings
|
|||||||
bool elevatedAutostart = false;
|
bool elevatedAutostart = false;
|
||||||
bool autoCorrect = false;
|
bool autoCorrect = false;
|
||||||
bool writeLog = true;
|
bool writeLog = true;
|
||||||
bool screenshotAutoOpenEditor = false;
|
|
||||||
bool useTesseract = false;
|
|
||||||
std::wstring tesseractPath;
|
|
||||||
std::wstring tesseractLanguages = L"ukr+eng+rus";
|
|
||||||
int autoMaxRank = 0;
|
int autoMaxRank = 0;
|
||||||
int autoMinLength = 3;
|
int autoMinLength = 3;
|
||||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||||
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
||||||
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
||||||
std::vector<std::wstring> autoNever;
|
std::vector<std::wstring> autoNever;
|
||||||
std::map<std::wstring, std::vector<std::wstring>> autoWords;
|
|
||||||
std::vector<std::wstring> autoExcluded{
|
std::vector<std::wstring> autoExcluded{
|
||||||
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
||||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
||||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V",
|
||||||
screenshot = L"Win+Shift+S";
|
screenshot = L"Win+Shift+S";
|
||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "../vendor/json.hpp"
|
|
||||||
#include "platform.h"
|
|
||||||
#include <sstream>
|
|
||||||
|
|
||||||
namespace ks
|
|
||||||
{
|
|
||||||
using DiagnosticJson = nlohmann::json;
|
|
||||||
struct OperationLogRow
|
|
||||||
{
|
|
||||||
std::wstring time, process, command, source, target, outcome, details;
|
|
||||||
bool problem = true;
|
|
||||||
};
|
|
||||||
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
|
|
||||||
{
|
|
||||||
std::vector<OperationLogRow> rows;
|
|
||||||
std::wistringstream stream(contents);
|
|
||||||
std::wstring line;
|
|
||||||
OperationLogRow *legacy = nullptr;
|
|
||||||
while (std::getline(stream, line))
|
|
||||||
{
|
|
||||||
if (!line.empty() && line.back() == L'\r')
|
|
||||||
line.pop_back();
|
|
||||||
if (line.empty())
|
|
||||||
continue;
|
|
||||||
auto marker = line.find(L" Operation {");
|
|
||||||
if (marker != line.npos)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
|
|
||||||
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
|
|
||||||
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
|
|
||||||
field("source"), field("target"), field("outcome")};
|
|
||||||
row.problem = j.value("problem", true);
|
|
||||||
row.details =
|
|
||||||
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
|
||||||
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
|
||||||
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
|
||||||
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
|
|
||||||
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
|
||||||
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
|
||||||
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
|
||||||
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
|
|
||||||
L"\r\n\r\nContext and stages:\r\n" + field("context");
|
|
||||||
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
|
|
||||||
if (at == 0 || row.details[at - 1] != L'\r')
|
|
||||||
row.details.insert(at++, 1, L'\r');
|
|
||||||
rows.push_back(std::move(row));
|
|
||||||
legacy = nullptr;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
catch (...)
|
|
||||||
{
|
|
||||||
} // Preserve malformed and old records for diagnosis.
|
|
||||||
}
|
|
||||||
if (line.front() != L' ' || !legacy)
|
|
||||||
{
|
|
||||||
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
|
|
||||||
legacy = &rows.back();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
legacy->details += L"\r\n" + line;
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
|
|
||||||
const std::wstring &expected)
|
|
||||||
{
|
|
||||||
if (actual == expected)
|
|
||||||
return L"Paste verified";
|
|
||||||
if (actual == original)
|
|
||||||
return L"Original text remained";
|
|
||||||
return L"Readback differs";
|
|
||||||
}
|
|
||||||
} // namespace ks
|
|
||||||
@@ -45,15 +45,12 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
cmd = 21;
|
cmd = 21;
|
||||||
else if (arg == L"--exit")
|
else if (arg == L"--exit")
|
||||||
cmd = 99;
|
cmd = 99;
|
||||||
else if (arg == L"--check-update")
|
|
||||||
cmd = 23;
|
|
||||||
LocalFree(argv);
|
LocalFree(argv);
|
||||||
if (existing)
|
if (existing)
|
||||||
{
|
{
|
||||||
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
if (window)
|
if (window)
|
||||||
{
|
{
|
||||||
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
|
|
||||||
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
||||||
DWORD_PTR result{};
|
DWORD_PTR result{};
|
||||||
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
||||||
|
|||||||
-115
@@ -271,119 +271,4 @@ std::wstring recognizeText(const Clip &clip)
|
|||||||
WindowsDeleteString(text);
|
WindowsDeleteString(text);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
std::filesystem::path tesseractExecutable(const Settings &s)
|
|
||||||
{
|
|
||||||
if (!s.tesseractPath.empty())
|
|
||||||
{
|
|
||||||
std::filesystem::path path(s.tesseractPath);
|
|
||||||
if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe");
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"})
|
|
||||||
{
|
|
||||||
wchar_t base[32768]{};
|
|
||||||
auto length = GetEnvironmentVariableW(variable, base, std::size(base));
|
|
||||||
if (!length || length >= std::size(base)) continue;
|
|
||||||
auto root = std::filesystem::path(base);
|
|
||||||
for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"})
|
|
||||||
if (std::filesystem::is_regular_file(root / relative)) return root / relative;
|
|
||||||
}
|
|
||||||
throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options.");
|
|
||||||
}
|
|
||||||
std::wstring ocrProfile(const Settings &s)
|
|
||||||
{
|
|
||||||
return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows";
|
|
||||||
}
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
struct TessLibrary
|
|
||||||
{
|
|
||||||
HMODULE module{};
|
|
||||||
explicit TessLibrary(const Settings &s)
|
|
||||||
{
|
|
||||||
auto exe = tesseractExecutable(s);
|
|
||||||
if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist");
|
|
||||||
auto library = exe.parent_path() / L"libtesseract-5.dll";
|
|
||||||
module = LoadLibraryExW(library.c_str(), nullptr,
|
|
||||||
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
|
||||||
if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies.");
|
|
||||||
}
|
|
||||||
~TessLibrary() { if (module) FreeLibrary(module); }
|
|
||||||
template<class T> T symbol(const char *name)
|
|
||||||
{
|
|
||||||
auto address = GetProcAddress(module, name);
|
|
||||||
if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name);
|
|
||||||
T result; static_assert(sizeof(result) == sizeof(address));
|
|
||||||
memcpy(&result, &address, sizeof(result));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
struct TessEngine
|
|
||||||
{
|
|
||||||
TessLibrary library;
|
|
||||||
void *handle{};
|
|
||||||
void (*destroy)(void *){};
|
|
||||||
explicit TessEngine(const Settings &s) : library(s)
|
|
||||||
{
|
|
||||||
auto folder = tesseractExecutable(s).parent_path() / L"tessdata";
|
|
||||||
size_t start = 0;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
auto end = s.tesseractLanguages.find(L'+', start);
|
|
||||||
auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start);
|
|
||||||
if (!std::filesystem::is_regular_file(folder / (language + L".traineddata")))
|
|
||||||
throw std::runtime_error("Tesseract language is not installed: " + utf8(language));
|
|
||||||
if (end == std::wstring::npos) break;
|
|
||||||
start = end + 1;
|
|
||||||
} while (true);
|
|
||||||
auto create = library.symbol<void *(*)()>("TessBaseAPICreate");
|
|
||||||
destroy = library.symbol<void (*)(void *)>("TessBaseAPIDelete");
|
|
||||||
auto init = library.symbol<int (*)(void *, const char *, const char *)>("TessBaseAPIInit3");
|
|
||||||
handle = create();
|
|
||||||
if (!handle) throw std::runtime_error("Cannot create Tesseract engine");
|
|
||||||
if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0)
|
|
||||||
{
|
|
||||||
destroy(handle); handle = nullptr;
|
|
||||||
throw std::runtime_error("Cannot initialize the selected Tesseract languages");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
~TessEngine() { if (handle) destroy(handle); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
void validateTesseract(const Settings &s)
|
|
||||||
{
|
|
||||||
if (s.useTesseract) { TessEngine engine(s); }
|
|
||||||
}
|
|
||||||
std::wstring recognizeText(const Clip &clip, const Settings &s)
|
|
||||||
{
|
|
||||||
if (!s.useTesseract) return recognizeText(clip);
|
|
||||||
auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
|
||||||
if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult);
|
|
||||||
struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)};
|
|
||||||
// Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs.
|
|
||||||
TessEngine engine(s);
|
|
||||||
auto &lib = engine.library;
|
|
||||||
auto image = lib.symbol<void (*)(void *, const unsigned char *, int, int, int, int)>("TessBaseAPISetImage");
|
|
||||||
auto mode = lib.symbol<void (*)(void *, int)>("TessBaseAPISetPageSegMode");
|
|
||||||
auto recognize = lib.symbol<int (*)(void *, void *)>("TessBaseAPIRecognize");
|
|
||||||
auto getText = lib.symbol<char *(*)(void *)>("TessBaseAPIGetUTF8Text");
|
|
||||||
auto freeText = lib.symbol<void (*)(const char *)>("TessDeleteText");
|
|
||||||
auto createMonitor = lib.symbol<void *(*)()>("TessMonitorCreate");
|
|
||||||
auto freeMonitor = lib.symbol<void (*)(void *)>("TessMonitorDelete");
|
|
||||||
auto deadline = lib.symbol<void (*)(void *, int)>("TessMonitorSetDeadlineMSecs");
|
|
||||||
std::unique_ptr<void, void (*)(void *)> monitor(createMonitor(), freeMonitor);
|
|
||||||
if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor");
|
|
||||||
auto pixels = decodeImage(clip);
|
|
||||||
Bytes gray(size_t(pixels.width) * pixels.height);
|
|
||||||
for (size_t i = 0; i < gray.size(); ++i)
|
|
||||||
gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8);
|
|
||||||
image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width);
|
|
||||||
mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs.
|
|
||||||
deadline(monitor.get(), 30000);
|
|
||||||
if (recognize(engine.handle, monitor.get()) != 0)
|
|
||||||
throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds");
|
|
||||||
std::unique_ptr<char, void (*)(const char *)> text(getText(engine.handle), freeText);
|
|
||||||
if (!text) throw std::runtime_error("Tesseract returned no result");
|
|
||||||
return wide(text.get());
|
|
||||||
}
|
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -10,8 +10,4 @@ struct Pixels
|
|||||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||||
Format imageDib(const Pixels &);
|
Format imageDib(const Pixels &);
|
||||||
std::wstring recognizeText(const Clip &);
|
std::wstring recognizeText(const Clip &);
|
||||||
std::wstring recognizeText(const Clip &, const Settings &);
|
|
||||||
std::filesystem::path tesseractExecutable(const Settings &);
|
|
||||||
void validateTesseract(const Settings &);
|
|
||||||
std::wstring ocrProfile(const Settings &);
|
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "platform.h"
|
|
||||||
#include <uiautomation.h>
|
|
||||||
|
|
||||||
namespace ks
|
|
||||||
{
|
|
||||||
// Read-only UI Automation: never select, move the caret, or touch the clipboard.
|
|
||||||
class PasteProbe
|
|
||||||
{
|
|
||||||
IUIAutomation2 *automation{};
|
|
||||||
IUIAutomationElement *element{};
|
|
||||||
IUIAutomationTextPattern *pattern{};
|
|
||||||
|
|
||||||
public:
|
|
||||||
std::wstring status = L"not-requested";
|
|
||||||
PasteProbe(bool enabled, const std::wstring &original)
|
|
||||||
{
|
|
||||||
if (!enabled)
|
|
||||||
return;
|
|
||||||
status = L"accessibility-unavailable";
|
|
||||||
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
|
|
||||||
(void **)&automation)))
|
|
||||||
return;
|
|
||||||
automation->put_ConnectionTimeout(200);
|
|
||||||
automation->put_TransactionTimeout(200);
|
|
||||||
if (FAILED(automation->GetFocusedElement(&element)) || !element)
|
|
||||||
return;
|
|
||||||
BOOL password = TRUE;
|
|
||||||
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
|
|
||||||
{
|
|
||||||
status = L"protected-or-inaccessible-field";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (FAILED(element->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern,
|
|
||||||
(void **)&pattern)) ||
|
|
||||||
!pattern)
|
|
||||||
return;
|
|
||||||
auto selected = read(1, false);
|
|
||||||
auto normalize = [](std::wstring value)
|
|
||||||
{
|
|
||||||
for (size_t at = 0; (at = value.find(L"\r\n", at)) != value.npos;)
|
|
||||||
value.erase(at, 1);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
if (!selected || normalize(*selected) != normalize(original))
|
|
||||||
{
|
|
||||||
if (selected)
|
|
||||||
status = L"initial-selection-mismatch";
|
|
||||||
if (pattern)
|
|
||||||
pattern->Release();
|
|
||||||
pattern = nullptr;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status = L"ready";
|
|
||||||
}
|
|
||||||
PasteProbe(const PasteProbe &) = delete;
|
|
||||||
~PasteProbe()
|
|
||||||
{
|
|
||||||
if (pattern)
|
|
||||||
pattern->Release();
|
|
||||||
if (element)
|
|
||||||
element->Release();
|
|
||||||
if (automation)
|
|
||||||
automation->Release();
|
|
||||||
}
|
|
||||||
std::optional<std::wstring> read(int characters, bool allowCaret = true)
|
|
||||||
{
|
|
||||||
if (!pattern)
|
|
||||||
return {};
|
|
||||||
if (characters <= 0 || characters > 4096)
|
|
||||||
{
|
|
||||||
status = L"readback-size-limit";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
IUIAutomationElement *focused{};
|
|
||||||
BOOL same = FALSE;
|
|
||||||
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
|
|
||||||
{
|
|
||||||
automation->CompareElements(element, focused, &same);
|
|
||||||
}
|
|
||||||
if (!same)
|
|
||||||
{
|
|
||||||
if (focused)
|
|
||||||
focused->Release();
|
|
||||||
status = L"focused-control-changed";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
BOOL password = TRUE;
|
|
||||||
if (FAILED(focused->get_CurrentIsPassword(&password)) || password)
|
|
||||||
{
|
|
||||||
focused->Release();
|
|
||||||
status = L"protected-or-inaccessible-field";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
// Native Edit accessibility proxies can retain a text snapshot in the
|
|
||||||
// pattern object. Obtain a fresh pattern after paste, not the pre-paste one.
|
|
||||||
pattern->Release();
|
|
||||||
pattern = nullptr;
|
|
||||||
HRESULT refreshed =
|
|
||||||
focused->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern, (void **)&pattern);
|
|
||||||
focused->Release();
|
|
||||||
if (FAILED(refreshed) || !pattern)
|
|
||||||
{
|
|
||||||
status = L"accessibility-unavailable";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
IUIAutomationTextRangeArray *ranges{};
|
|
||||||
if (FAILED(pattern->GetSelection(&ranges)) || !ranges)
|
|
||||||
{
|
|
||||||
status = L"selection-unavailable";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
int count{};
|
|
||||||
ranges->get_Length(&count);
|
|
||||||
IUIAutomationTextRange *range{};
|
|
||||||
if (count == 1)
|
|
||||||
ranges->GetElement(0, &range);
|
|
||||||
ranges->Release();
|
|
||||||
if (!range)
|
|
||||||
{
|
|
||||||
status = L"selection-ambiguous";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
int difference{};
|
|
||||||
HRESULT hr = range->CompareEndpoints(TextPatternRangeEndpoint_Start, range,
|
|
||||||
TextPatternRangeEndpoint_End, &difference);
|
|
||||||
if (SUCCEEDED(hr) && difference == 0)
|
|
||||||
{
|
|
||||||
if (!allowCaret)
|
|
||||||
{
|
|
||||||
range->Release();
|
|
||||||
status = L"initial-selection-unavailable";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
int moved{};
|
|
||||||
hr = range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start, TextUnit_Character, -characters,
|
|
||||||
&moved);
|
|
||||||
if (SUCCEEDED(hr) && moved != -characters)
|
|
||||||
hr = E_FAIL;
|
|
||||||
}
|
|
||||||
BSTR value{};
|
|
||||||
if (SUCCEEDED(hr))
|
|
||||||
hr = range->GetText(8193, &value);
|
|
||||||
range->Release();
|
|
||||||
if (FAILED(hr))
|
|
||||||
{
|
|
||||||
SysFreeString(value);
|
|
||||||
status = L"range-read-failed";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
std::wstring text(value ? value : L"", SysStringLen(value));
|
|
||||||
SysFreeString(value);
|
|
||||||
if (text.size() > 8192)
|
|
||||||
{
|
|
||||||
status = L"readback-size-limit";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
status = L"read-back-via-accessibility";
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} // namespace ks
|
|
||||||
+2
-21
@@ -1,8 +1,6 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
#include "../vendor/json.hpp"
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iomanip>
|
|
||||||
#include <imm.h>
|
#include <imm.h>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -626,22 +624,6 @@ void diagnosticEvent(const std::wstring &message, bool problem)
|
|||||||
if (!incident.empty())
|
if (!incident.empty())
|
||||||
log(incident);
|
log(incident);
|
||||||
}
|
}
|
||||||
void diagnosticOperation(const std::string &json)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(logGate);
|
|
||||||
if (!loggingEnabled) return;
|
|
||||||
auto record = nlohmann::json::parse(json);
|
|
||||||
if (!record.value("problem", true))
|
|
||||||
{
|
|
||||||
// Keep successful operations only in the bounded, short-lived context.
|
|
||||||
// They are persisted with a subsequent failure, never as standalone rows.
|
|
||||||
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
|
|
||||||
nlohmann::json::error_handler_t::replace)), false, GetTickCount64());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
|
|
||||||
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
|
|
||||||
}
|
|
||||||
void log(const std::wstring &message)
|
void log(const std::wstring &message)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(logGate);
|
std::lock_guard lock(logGate);
|
||||||
@@ -663,9 +645,8 @@ void log(const std::wstring &message)
|
|||||||
std::ofstream f(path, std::ios::app);
|
std::ofstream f(path, std::ios::app);
|
||||||
SYSTEMTIME t;
|
SYSTEMTIME t;
|
||||||
GetLocalTime(&t);
|
GetLocalTime(&t);
|
||||||
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
|
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
|
||||||
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
|
<< t.wSecond << ' ' << utf8(message) << '\n';
|
||||||
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
|
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ class DiagnosticBuffer
|
|||||||
};
|
};
|
||||||
void setLoggingEnabled(bool enabled);
|
void setLoggingEnabled(bool enabled);
|
||||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||||
void diagnosticOperation(const std::string &json);
|
|
||||||
std::wstring readDiagnosticLog(bool previous);
|
std::wstring readDiagnosticLog(bool previous);
|
||||||
void clearDiagnosticLog(bool previous);
|
void clearDiagnosticLog(bool previous);
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
+56
-364
@@ -1,10 +1,8 @@
|
|||||||
|
#include "app.h"
|
||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
#include "ocr.h"
|
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <functional>
|
|
||||||
#include <unordered_set>
|
|
||||||
#include <windowsx.h>
|
#include <windowsx.h>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <commdlg.h>
|
#include <commdlg.h>
|
||||||
@@ -15,21 +13,14 @@ namespace
|
|||||||
{
|
{
|
||||||
// Only selection is exclusive; each completed capture owns an independent editor.
|
// Only selection is exclusive; each completed capture owns an independent editor.
|
||||||
HWND activeCapture{};
|
HWND activeCapture{};
|
||||||
// Keep titles stable and distinct for every editor opened during this session.
|
|
||||||
unsigned long long nextEditorIndex=1;
|
|
||||||
bool confirmScreenshotClose(HWND owner)
|
bool confirmScreenshotClose(HWND owner)
|
||||||
{
|
{
|
||||||
TASKDIALOGCONFIG dialog{sizeof(dialog)};
|
TASKDIALOGCONFIG dialog{sizeof(dialog)};
|
||||||
dialog.hwndParent=owner;
|
dialog.hwndParent=owner;
|
||||||
dialog.dwFlags=TDF_POSITION_RELATIVE_TO_WINDOW|TDF_ALLOW_DIALOG_CANCELLATION|TDF_USE_HICON_MAIN;
|
dialog.dwFlags=TDF_POSITION_RELATIVE_TO_WINDOW|TDF_ALLOW_DIALOG_CANCELLATION;
|
||||||
const int iconSize=MulDiv(32,GetDpiForWindow(owner),96);
|
|
||||||
auto icon=(HICON)LoadImageW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(IDI_SCREENSHOT_QUESTION),
|
|
||||||
IMAGE_ICON,iconSize,iconSize,0);
|
|
||||||
dialog.hMainIcon=icon;
|
|
||||||
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
|
||||||
dialog.pszWindowTitle=L"KSwitcher";
|
dialog.pszWindowTitle=L"KSwitcher";
|
||||||
dialog.pszMainInstruction=L"Close without copying?";
|
dialog.pszContent=L"Close without copying this screenshot to the clipboard?";
|
||||||
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
|
|
||||||
dialog.nDefaultButton=IDNO;
|
dialog.nDefaultButton=IDNO;
|
||||||
dialog.lpCallbackData=(LONG_PTR)owner;
|
dialog.lpCallbackData=(LONG_PTR)owner;
|
||||||
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
|
||||||
@@ -49,11 +40,10 @@ bool confirmScreenshotClose(HWND owner)
|
|||||||
};
|
};
|
||||||
int button=IDNO;
|
int button=IDNO;
|
||||||
auto result=TaskDialogIndirect(&dialog,&button,nullptr,nullptr);
|
auto result=TaskDialogIndirect(&dialog,&button,nullptr,nullptr);
|
||||||
if(icon) DestroyIcon(icon);
|
|
||||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||||
return SUCCEEDED(result) && button==IDYES;
|
return SUCCEEDED(result) && button==IDYES;
|
||||||
}
|
}
|
||||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
|
constexpr int ToolIds[]{10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||||
struct Surface
|
struct Surface
|
||||||
{
|
{
|
||||||
HDC dc = CreateCompatibleDC(nullptr);
|
HDC dc = CreateCompatibleDC(nullptr);
|
||||||
@@ -123,53 +113,14 @@ bool chooseTextFont(HWND owner,Mark &m)
|
|||||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
constexpr int CursorTools[]{10,11,22,23,24,25,19};
|
|
||||||
HCURSOR makeToolCursor(int tool,int size)
|
|
||||||
{
|
|
||||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
|
||||||
auto bitmap=CreateBitmap(size,size*2,1,1,nullptr);
|
|
||||||
auto dc=CreateCompatibleDC(nullptr);
|
|
||||||
if(!bitmap || !dc) { if(bitmap) DeleteObject(bitmap); if(dc) DeleteDC(dc); return nullptr; }
|
|
||||||
auto oldBitmap=SelectObject(dc,bitmap);
|
|
||||||
PatBlt(dc,0,0,size,size,WHITENESS); PatBlt(dc,0,size,size,size,BLACKNESS);
|
|
||||||
auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
|
|
||||||
auto draw=[&](int offset,int width,COLORREF color) {
|
|
||||||
auto pen=CreatePen(PS_SOLID,width,color); auto oldPen=SelectObject(dc,pen);
|
|
||||||
auto x=[&](int v){return MulDiv(v,size,32);};
|
|
||||||
auto y=[&](int v){return offset+MulDiv(v,size,32);};
|
|
||||||
auto line=[&](int a,int b,int c,int d){MoveToEx(dc,x(a),y(b),nullptr); LineTo(dc,x(c),y(d));};
|
|
||||||
line(1,7,13,7); line(7,1,7,13); // The cross center is the exact drawing point.
|
|
||||||
switch(tool) {
|
|
||||||
case 10:
|
|
||||||
Ellipse(dc,x(17),y(17),x(30),y(30));
|
|
||||||
line(22,21,24,20); line(24,20,24,27); line(21,27,27,27); break;
|
|
||||||
case 11: line(18,29,29,18); line(22,18,29,18); line(29,18,29,25); break;
|
|
||||||
case 22: line(18,29,29,18); break;
|
|
||||||
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
|
||||||
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
|
||||||
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
|
||||||
case 19:
|
|
||||||
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
|
|
||||||
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
|
|
||||||
}
|
|
||||||
SelectObject(dc,oldPen); DeleteObject(pen);
|
|
||||||
};
|
|
||||||
int outline=std::max(3,MulDiv(4,size,32)),stroke=std::max(1,MulDiv(2,size,32));
|
|
||||||
draw(0,outline,RGB(0,0,0));
|
|
||||||
draw(size,outline,RGB(255,255,255)); draw(size,stroke,RGB(0,0,0));
|
|
||||||
SelectObject(dc,oldBrush); SelectObject(dc,oldBitmap); DeleteDC(dc);
|
|
||||||
ICONINFO info{}; info.fIcon=FALSE; info.xHotspot=info.yHotspot=MulDiv(7,size,32); info.hbmMask=bitmap;
|
|
||||||
auto cursor=(HCURSOR)CreateIconIndirect(&info); DeleteObject(bitmap); return cursor;
|
|
||||||
}
|
|
||||||
struct Capture
|
struct Capture
|
||||||
{
|
{
|
||||||
ScreenshotHost &app;
|
App &app;
|
||||||
std::unique_ptr<Surface> image;
|
std::unique_ptr<Surface> image;
|
||||||
std::unique_ptr<Surface> dimmed, frame;
|
std::unique_ptr<Surface> dimmed, frame;
|
||||||
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
|
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
|
||||||
bool autoOpenEditor;
|
|
||||||
bool highlight{};
|
bool highlight{};
|
||||||
bool pointer=false, freehand=true;
|
bool freehand=true;
|
||||||
bool straight{}, textTool{};
|
bool straight{}, textTool{};
|
||||||
Mark textDefaults{};
|
Mark textDefaults{};
|
||||||
ScreenshotShape shape=ScreenshotShape::None;
|
ScreenshotShape shape=ScreenshotShape::None;
|
||||||
@@ -178,11 +129,6 @@ struct Capture
|
|||||||
COLORREF currentColor=RGB(225,42,50);
|
COLORREF currentColor=RGB(225,42,50);
|
||||||
COLORREF customColors[16]{};
|
COLORREF customColors[16]{};
|
||||||
Mark stroke{};
|
Mark stroke{};
|
||||||
bool strokeConstrained{};
|
|
||||||
size_t strokeAnchor{};
|
|
||||||
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
|
||||||
bool strokeAxisSettled{};
|
|
||||||
std::vector<POINT> strokeMotion;
|
|
||||||
POINT start{}, end{};
|
POINT start{}, end{};
|
||||||
RECT selection{}, view{};
|
RECT selection{}, view{};
|
||||||
std::vector<Mark> marks;
|
std::vector<Mark> marks;
|
||||||
@@ -202,23 +148,20 @@ struct Capture
|
|||||||
HICON toolIcons[3][15]{};
|
HICON toolIcons[3][15]{};
|
||||||
int iconSize{};
|
int iconSize{};
|
||||||
HCURSOR pencilCursor{};
|
HCURSOR pencilCursor{};
|
||||||
HCURSOR drawingCursors[std::size(CursorTools)]{};
|
|
||||||
int pencilCursorSize{};
|
int pencilCursorSize{};
|
||||||
int unit = 1, panelHeight = 1, statusHeight = 0;
|
int unit = 1, panelHeight = 1, statusHeight = 0;
|
||||||
int hoveredTool=0;
|
int hoveredTool=0;
|
||||||
MarkPart hoveredPart=MarkPart::None;
|
MarkPart hoveredPart=MarkPart::None;
|
||||||
std::wstring statusText;
|
std::wstring statusText;
|
||||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
explicit Capture(App &a) : app(a) {}
|
||||||
~Capture() {
|
~Capture() {
|
||||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||||
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
|
|
||||||
if (font) DeleteObject(font);
|
if (font) DeleteObject(font);
|
||||||
if(inlineFont) DeleteObject(inlineFont);
|
if(inlineFont) DeleteObject(inlineFont);
|
||||||
for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon);
|
for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon);
|
||||||
}
|
}
|
||||||
void remember(const std::vector<Mark> &previous)
|
void remember(const std::vector<Mark> &previous)
|
||||||
{
|
{
|
||||||
copied=false;
|
|
||||||
history.remember(previous);
|
history.remember(previous);
|
||||||
syncHistory();
|
syncHistory();
|
||||||
}
|
}
|
||||||
@@ -244,7 +187,7 @@ void updateStatus(Capture &c)
|
|||||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
||||||
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
||||||
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
||||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows";
|
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Arrow endpoints can snap to numbered markers";
|
||||||
else if(c.selected>=0) {
|
else if(c.selected>=0) {
|
||||||
const auto &m=c.marks[c.selected];
|
const auto &m=c.marks[c.selected];
|
||||||
if(c.selectedLabel || part==MarkPart::Label)
|
if(c.selectedLabel || part==MarkPart::Label)
|
||||||
@@ -252,12 +195,11 @@ void updateStatus(Capture &c)
|
|||||||
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
|
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
|
||||||
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
|
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
|
||||||
}
|
}
|
||||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
|
||||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
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): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
|
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 over objects = linked connector | V = pointer";
|
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 | V = select and edit | Ctrl+C = copy screenshot";
|
else text=L"Number (N): click to add | Drag objects to move | Ctrl+C = copy screenshot";
|
||||||
if(text!=c.statusText) {
|
if(text!=c.statusText) {
|
||||||
c.statusText=std::move(text);
|
c.statusText=std::move(text);
|
||||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||||
@@ -298,15 +240,6 @@ void positionInlineText(Capture &c)
|
|||||||
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
||||||
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
}
|
}
|
||||||
Mark textStyleDefaults(const Mark &source)
|
|
||||||
{
|
|
||||||
// Reuse typography only, never an object's angle, position, identity or links.
|
|
||||||
Mark style{};
|
|
||||||
style.fontName=source.fontName; style.fontSize=source.fontSize;
|
|
||||||
style.fontWeight=source.fontWeight; style.fontItalic=source.fontItalic;
|
|
||||||
style.color=source.color;
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
void finishInlineText(HWND w,Capture &c,bool commit)
|
void finishInlineText(HWND w,Capture &c,bool commit)
|
||||||
{
|
{
|
||||||
if(!c.inlineText) return;
|
if(!c.inlineText) return;
|
||||||
@@ -329,9 +262,8 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
|||||||
} else if(!empty) {
|
} else if(!empty) {
|
||||||
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
|
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
|
||||||
}
|
}
|
||||||
if(!empty) c.textDefaults=textStyleDefaults(c.textDraft);
|
if(!empty) c.textDefaults=c.textDraft;
|
||||||
}
|
}
|
||||||
if(commit) resolveScreenshotLinks(c.marks);
|
|
||||||
c.textIndex=-1; c.editingLabel=false;
|
c.textIndex=-1; c.editingLabel=false;
|
||||||
InvalidateRect(w,nullptr,FALSE);
|
InvalidateRect(w,nullptr,FALSE);
|
||||||
}
|
}
|
||||||
@@ -568,11 +500,6 @@ void layout(HWND w, Capture &c)
|
|||||||
int dpi = GetDpiForWindow(w);
|
int dpi = GetDpiForWindow(w);
|
||||||
int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi);
|
int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi);
|
||||||
if(cursorSize!=c.pencilCursorSize) {
|
if(cursorSize!=c.pencilCursorSize) {
|
||||||
for(size_t i=0;i<std::size(CursorTools);++i) {
|
|
||||||
auto cursor=makeToolCursor(CursorTools[i],cursorSize);
|
|
||||||
if(c.drawingCursors[i]) DestroyCursor(c.drawingCursors[i]);
|
|
||||||
c.drawingCursors[i]=cursor;
|
|
||||||
}
|
|
||||||
auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0);
|
auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0);
|
||||||
if(cursor) {
|
if(cursor) {
|
||||||
if(c.pencilCursor) DestroyCursor(c.pencilCursor);
|
if(c.pencilCursor) DestroyCursor(c.pencilCursor);
|
||||||
@@ -599,7 +526,7 @@ void layout(HWND w, Capture &c)
|
|||||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||||
r.bottom-=c.statusHeight;
|
r.bottom-=c.statusHeight;
|
||||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
|
const int positions[]{8,44,80,116,152,188,224,260,296,332,380,416,464};
|
||||||
static_assert(std::size(positions)==std::size(ToolIds));
|
static_assert(std::size(positions)==std::size(ToolIds));
|
||||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||||
{
|
{
|
||||||
@@ -638,15 +565,6 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
|||||||
{
|
{
|
||||||
if((c.arrow || c.highlight || c.freehand || c.textTool) && (GetKeyState(VK_CONTROL)&0x8000)) return {-1,MarkPart::None};
|
if((c.arrow || c.highlight || c.freehand || c.textTool) && (GetKeyState(VK_CONTROL)&0x8000)) return {-1,MarkPart::None};
|
||||||
double tolerance=double(std::max(6,c.unit/6))*c.image->width/std::max(1L,c.view.right-c.view.left);
|
double tolerance=double(std::max(6,c.unit/6))*c.image->width/std::max(1L,c.view.right-c.view.left);
|
||||||
// Badges are painted above connectors. Their visible bodies must also win
|
|
||||||
// hit testing, even when an attached connector is currently selected.
|
|
||||||
for(int i=int(c.marks.size())-1;i>=0;--i) {
|
|
||||||
const auto &m=c.marks[i];
|
|
||||||
if(m.number && std::hypot(double(p.x-m.from.x),double(p.y-m.from.y))<=m.radius) {
|
|
||||||
auto part=hitScreenshotMark(m,p,tolerance);
|
|
||||||
return {i,part};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(c.selected>=0)
|
if(c.selected>=0)
|
||||||
{
|
{
|
||||||
const auto &m=c.marks[c.selected];
|
const auto &m=c.marks[c.selected];
|
||||||
@@ -668,24 +586,20 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
|||||||
}
|
}
|
||||||
return {-1,MarkPart::None};
|
return {-1,MarkPart::None};
|
||||||
}
|
}
|
||||||
HCURSOR toolCursor(const Capture &c)
|
void captureCursor(HWND w, Capture &c)
|
||||||
{
|
{
|
||||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
LPCWSTR cursor=IDC_CROSS;
|
||||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
if(c.editing)
|
||||||
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);
|
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||||
LPCWSTR cursor=nullptr;
|
auto hit=c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false));
|
||||||
|
if(!c.dragging && !PtInRect(&c.view,p) && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
||||||
|
if(!c.dragging && !PtInRect(&c.view,p)) cursor=IDC_ARROW;
|
||||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
||||||
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
||||||
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
||||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness)
|
||||||
|
{
|
||||||
const auto &mark=c.marks[hit.first];
|
const auto &mark=c.marks[hit.first];
|
||||||
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
|
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
|
||||||
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
|
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
|
||||||
@@ -694,15 +608,8 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
|||||||
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
|
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
|
||||||
cursor=cursors[axis];
|
cursor=cursors[axis];
|
||||||
}
|
}
|
||||||
return cursor?LoadCursorW(nullptr,cursor):inside?toolCursor(c):LoadCursorW(nullptr,IDC_ARROW);
|
|
||||||
}
|
}
|
||||||
void captureCursor(HWND w, Capture &c)
|
SetCursor(c.editing && c.freehand && cursor==IDC_CROSS && c.pencilCursor?c.pencilCursor:LoadCursorW(nullptr,cursor));
|
||||||
{
|
|
||||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
|
||||||
bool inside=PtInRect(&c.view,p)!=FALSE;
|
|
||||||
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
|
|
||||||
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
|
|
||||||
SetCursor(actionCursor(c,hit,inside || c.dragging));
|
|
||||||
if(c.editing) {
|
if(c.editing) {
|
||||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||||
c.hoveredPart=markAt(c,imagePoint(c,p,false)).second;
|
c.hoveredPart=markAt(c,imagePoint(c,p,false)).second;
|
||||||
@@ -769,7 +676,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
{
|
{
|
||||||
auto dc=item.hDC;
|
auto dc=item.hDC;
|
||||||
RECT r=item.rcItem;
|
RECT r=item.rcItem;
|
||||||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
bool active=(item.CtlID==10 && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
||||||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
||||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
||||||
bool disabled=item.itemState&ODS_DISABLED;
|
bool disabled=item.itemState&ODS_DISABLED;
|
||||||
@@ -785,7 +692,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||||
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
auto icon=c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||||
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
||||||
if(item.CtlID==20) {
|
if(item.CtlID==20) {
|
||||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
||||||
@@ -813,13 +720,14 @@ LRESULT CALLBACK toolHover(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWO
|
|||||||
if(msg==WM_MOUSELEAVE) InvalidateRect(w,nullptr,FALSE);
|
if(msg==WM_MOUSELEAVE) InvalidateRect(w,nullptr,FALSE);
|
||||||
return DefSubclassProc(w,msg,wp,lp);
|
return DefSubclassProc(w,msg,wp,lp);
|
||||||
}
|
}
|
||||||
void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard)
|
void copy(HWND w, Capture &c)
|
||||||
{
|
{
|
||||||
finishInlineText(w,c,true);
|
finishInlineText(w,c,true);
|
||||||
// The resident message window keeps ownership if the editor is closed later.
|
// The resident message window owns the clipboard after this editor closes.
|
||||||
auto clip=screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks);
|
auto clip=screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks);
|
||||||
publish(c.app.window, clip);
|
writeClipboard(c.app.window, clip);
|
||||||
c.copied=true;
|
c.copied=true;
|
||||||
|
DestroyWindow(w);
|
||||||
}
|
}
|
||||||
void edit(HWND w, Capture &c)
|
void edit(HWND w, Capture &c)
|
||||||
{
|
{
|
||||||
@@ -837,16 +745,13 @@ void edit(HWND w, Capture &c)
|
|||||||
POINT cursor; GetCursorPos(&cursor);
|
POINT cursor; GetCursorPos(&cursor);
|
||||||
GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTOPRIMARY), &monitor);
|
GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTOPRIMARY), &monitor);
|
||||||
ShowWindow(w,SW_HIDE);
|
ShowWindow(w,SW_HIDE);
|
||||||
SetWindowLongPtrW(w,GWLP_HWNDPARENT,0);
|
|
||||||
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
|
|
||||||
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
|
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
|
||||||
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
|
SetWindowTextW(w,L"KSwitcher — Screenshot");
|
||||||
SetWindowTextW(w,title.c_str());
|
|
||||||
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
|
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
|
||||||
InitCommonControlsEx(&statusControls);
|
InitCommonControlsEx(&statusControls);
|
||||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
const wchar_t *labels[]{L"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",
|
||||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||||
static_assert(std::size(labels)==std::size(ToolIds));
|
static_assert(std::size(labels)==std::size(ToolIds));
|
||||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||||
@@ -886,27 +791,6 @@ void edit(HWND w, Capture &c)
|
|||||||
std::min(LONG(MulDiv(700,GetDpiForWindow(w),96)),work.bottom-work.top-40),SWP_FRAMECHANGED|SWP_SHOWWINDOW);
|
std::min(LONG(MulDiv(700,GetDpiForWindow(w),96)),work.bottom-work.top-40),SWP_FRAMECHANGED|SWP_SHOWWINDOW);
|
||||||
layout(w,c); SetForegroundWindow(w); SetFocus(w);
|
layout(w,c); SetForegroundWindow(w); SetFocus(w);
|
||||||
}
|
}
|
||||||
void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=writeClipboard)
|
|
||||||
{
|
|
||||||
if(!c.app.notify) { edit(w,c); return; }
|
|
||||||
auto r=region(c.start,c.end);
|
|
||||||
if(r.right-r.left<2 || r.bottom-r.top<2) return;
|
|
||||||
Surface cropped(r.right-r.left,r.bottom-r.top);
|
|
||||||
BitBlt(cropped.dc,0,0,cropped.width,cropped.height,c.image->dc,r.left,r.top,SRCCOPY);
|
|
||||||
auto clip=screenshotClip(cropped.dc,cropped.width,cropped.height,{});
|
|
||||||
publish(c.app.window,clip);
|
|
||||||
c.copied=true;
|
|
||||||
// A successful toast submission does not guarantee that Windows shows a banner.
|
|
||||||
if(c.autoOpenEditor) edit(w,c);
|
|
||||||
// Leave fullscreen capture before submitting the toast: Windows may suppress
|
|
||||||
// banners while a fullscreen application is visible.
|
|
||||||
else ShowWindow(w,SW_HIDE);
|
|
||||||
bool notified=c.app.notify(clip);
|
|
||||||
if(!c.autoOpenEditor) {
|
|
||||||
if(notified) DestroyWindow(w);
|
|
||||||
else edit(w,c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
auto c = reinterpret_cast<Capture *>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
auto c = reinterpret_cast<Capture *>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
||||||
@@ -922,8 +806,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
switch (msg)
|
switch (msg)
|
||||||
{
|
{
|
||||||
case WM_CLOSE:
|
case WM_CLOSE:
|
||||||
finishInlineText(w,*c,true);
|
if(c->editing && !c->copied && !confirmScreenshotClose(w)) return 0;
|
||||||
if(c->editing && (!c->copied || c->dragging) && !confirmScreenshotClose(w)) return 0;
|
|
||||||
DestroyWindow(w); return 0;
|
DestroyWindow(w); return 0;
|
||||||
case FinishTextMessage:
|
case FinishTextMessage:
|
||||||
if(c->inlineText && (HWND)lp==c->inlineText && (wp!=0 || GetFocus()!=c->inlineText)) {
|
if(c->inlineText && (HWND)lp==c->inlineText && (wp!=0 || GetFocus()!=c->inlineText)) {
|
||||||
@@ -938,7 +821,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case WM_DRAWITEM:
|
case WM_DRAWITEM:
|
||||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
if((wp>=10 && wp<=13) || (wp>=18 && wp<=26) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||||
break;
|
break;
|
||||||
case WM_CTLCOLORSTATIC:
|
case WM_CTLCOLORSTATIC:
|
||||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||||
@@ -965,8 +848,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto draft=c->marks[c->selected];
|
auto draft=c->marks[c->selected];
|
||||||
if(chooseTextFont(w,draft)) {
|
if(chooseTextFont(w,draft)) {
|
||||||
c->remember(c->marks); c->marks[c->selected]=draft;
|
c->remember(c->marks); c->marks[c->selected]=draft;
|
||||||
c->textDefaults=textStyleDefaults(draft);
|
c->textDefaults=draft.isLine()?screenshotArrowLabel(draft):draft;
|
||||||
resolveScreenshotLinks(c->marks);
|
|
||||||
}
|
}
|
||||||
} else chooseTextFont(w,c->textDefaults);
|
} else chooseTextFont(w,c->textDefaults);
|
||||||
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
@@ -1020,18 +902,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto &mark=c->marks[c->selected];
|
auto &mark=c->marks[c->selected];
|
||||||
int &value=mark.isText()?mark.fontSize:mark.thickness;
|
int &value=mark.isText()?mark.fontSize:mark.thickness;
|
||||||
int next=std::clamp(value+(LOWORD(wp)==16?1:-1),mark.isText()?6:mark.isHighlight()?4:1,mark.isText()?200:mark.isHighlight()?100:30);
|
int next=std::clamp(value+(LOWORD(wp)==16?1:-1),mark.isText()?6:mark.isHighlight()?4:1,mark.isText()?200:mark.isHighlight()?100:30);
|
||||||
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
if(next!=value) { c->remember(c->marks); value=next; }
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||||
{
|
{
|
||||||
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
|
||||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
if(changed) { c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||||
c->syncHistory();
|
c->syncHistory();
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26))
|
||||||
{
|
{
|
||||||
c->pointer=LOWORD(wp)==27;
|
|
||||||
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||||
c->straight=LOWORD(wp)==22;
|
c->straight=LOWORD(wp)==22;
|
||||||
c->textTool=LOWORD(wp)==26;
|
c->textTool=LOWORD(wp)==26;
|
||||||
@@ -1045,18 +926,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
for(int id=22;id<=26;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
case WM_KEYDOWN:
|
case WM_KEYDOWN:
|
||||||
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
|
|
||||||
c->strokeConstrained=true;
|
|
||||||
c->strokeAnchor=c->stroke.points.size()-1;
|
|
||||||
c->strokeAxis=0;
|
|
||||||
c->strokeAxisSettled=false;
|
|
||||||
c->strokeMotion={c->stroke.points.back()};
|
|
||||||
}
|
|
||||||
if (wp==VK_ESCAPE) {
|
if (wp==VK_ESCAPE) {
|
||||||
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1088,7 +962,6 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
InvalidateRect(w,nullptr,FALSE);
|
InvalidateRect(w,nullptr,FALSE);
|
||||||
}
|
}
|
||||||
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
|
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
|
||||||
if(wp=='V') SendMessageW(w,WM_COMMAND,27,0);
|
|
||||||
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
|
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
|
||||||
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
|
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
|
||||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||||
@@ -1112,9 +985,6 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case WM_KEYUP:
|
|
||||||
if(wp==VK_SHIFT) c->strokeConstrained=false;
|
|
||||||
return 0;
|
|
||||||
case WM_LBUTTONDBLCLK:
|
case WM_LBUTTONDBLCLK:
|
||||||
if(c->editing && !c->dragging) {
|
if(c->editing && !c->dragging) {
|
||||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||||
@@ -1151,7 +1021,6 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
InvalidateRect(w,nullptr,FALSE); return 0;
|
InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
}
|
}
|
||||||
c->selected=-1; c->selectedLabel=false;
|
c->selected=-1; c->selectedLabel=false;
|
||||||
if(c->pointer) { InvalidateRect(w,nullptr,FALSE); updateStatus(*c); return 0; }
|
|
||||||
}
|
}
|
||||||
if(c->editing && c->textTool) {
|
if(c->editing && c->textTool) {
|
||||||
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
|
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
|
||||||
@@ -1171,7 +1040,6 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
|
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
|
||||||
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||||
c->stroke.points.push_back(p);
|
c->stroke.points.push_back(p);
|
||||||
c->strokeConstrained=false;
|
|
||||||
}
|
}
|
||||||
c->dragging=true; SetCapture(w);
|
c->dragging=true; SetCapture(w);
|
||||||
}
|
}
|
||||||
@@ -1209,63 +1077,6 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
|
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
|
||||||
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
||||||
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
||||||
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
|
|
||||||
if(constrained) {
|
|
||||||
if(!c->strokeConstrained) {
|
|
||||||
c->strokeAnchor=c->stroke.points.size()-1;
|
|
||||||
c->strokeAxis=0;
|
|
||||||
c->strokeAxisSettled=false;
|
|
||||||
c->strokeMotion={c->stroke.points.back()};
|
|
||||||
}
|
|
||||||
auto anchor=c->stroke.points[c->strokeAnchor];
|
|
||||||
// Measure the actual mouse trajectory, not its snapped projection.
|
|
||||||
auto raw=c->end;
|
|
||||||
if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y)
|
|
||||||
c->strokeMotion.push_back(raw);
|
|
||||||
while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 ||
|
|
||||||
std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16))
|
|
||||||
c->strokeMotion.erase(c->strokeMotion.begin());
|
|
||||||
auto chooseAxis=[](POINT from,POINT to) {
|
|
||||||
LONG dx=to.x-from.x,dy=to.y-from.y;
|
|
||||||
constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees)
|
|
||||||
if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1;
|
|
||||||
if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2;
|
|
||||||
return (dx<0)==(dy<0)?3:4;
|
|
||||||
};
|
|
||||||
auto distance=[](int axis,POINT from,POINT to) {
|
|
||||||
double dx=to.x-from.x,dy=to.y-from.y;
|
|
||||||
return axis==1?std::abs(dy):axis==2?std::abs(dx):
|
|
||||||
std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0);
|
|
||||||
};
|
|
||||||
if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) {
|
|
||||||
c->strokeAxis=chooseAxis(anchor,c->end);
|
|
||||||
// A one-pixel first movement must not lock a diagonal gesture to an axis.
|
|
||||||
c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8;
|
|
||||||
}
|
|
||||||
else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) {
|
|
||||||
// Keep the completed segment and turn at its endpoint.
|
|
||||||
auto corner=c->stroke.points.back();
|
|
||||||
int nextAxis=chooseAxis(c->strokeMotion.front(),raw);
|
|
||||||
if(nextAxis!=c->strokeAxis) {
|
|
||||||
c->strokeAnchor=c->stroke.points.size()-1;
|
|
||||||
anchor=corner;
|
|
||||||
c->strokeAxis=nextAxis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(c->strokeAxis==1) c->end.y=anchor.y;
|
|
||||||
else if(c->strokeAxis==2) c->end.x=anchor.x;
|
|
||||||
else if(c->strokeAxis>=3) {
|
|
||||||
int sign=c->strokeAxis==3?1:-1;
|
|
||||||
LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0);
|
|
||||||
LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1);
|
|
||||||
LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y;
|
|
||||||
delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY));
|
|
||||||
c->end={anchor.x+delta,anchor.y+sign*delta};
|
|
||||||
}
|
|
||||||
// Only the current segment is a preview; earlier corners stay fixed.
|
|
||||||
c->stroke.points.resize(c->strokeAnchor+1);
|
|
||||||
}
|
|
||||||
c->strokeConstrained=constrained;
|
|
||||||
auto last=c->stroke.points.back();
|
auto last=c->stroke.points.back();
|
||||||
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
||||||
c->stroke.to=c->end;
|
c->stroke.to=c->end;
|
||||||
@@ -1278,11 +1089,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (msg==WM_LBUTTONUP)
|
if (msg==WM_LBUTTONUP)
|
||||||
{
|
{
|
||||||
c->dragging=false; ReleaseCapture();
|
c->dragging=false; ReleaseCapture();
|
||||||
if (!c->editing) { finishSelection(w,*c); return 0; }
|
if (!c->editing) edit(w,*c);
|
||||||
else if(c->part!=MarkPart::None)
|
else if(c->part!=MarkPart::None)
|
||||||
{
|
{
|
||||||
if((c->part==MarkPart::Start || c->part==MarkPart::End) &&
|
if(c->part==MarkPart::Start || c->part==MarkPart::End)
|
||||||
(c->start.x!=c->end.x || c->start.y!=c->end.y))
|
|
||||||
{
|
{
|
||||||
auto &mark=c->marks[c->selected];
|
auto &mark=c->marks[c->selected];
|
||||||
linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6);
|
linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6);
|
||||||
@@ -1416,11 +1226,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_NCDESTROY:
|
case WM_NCDESTROY:
|
||||||
if (activeCapture == w) activeCapture=nullptr;
|
if (activeCapture == w) activeCapture=nullptr;
|
||||||
SetWindowLongPtrW(w,GWLP_USERDATA,0);
|
SetWindowLongPtrW(w,GWLP_USERDATA,0);
|
||||||
if (c->owned) {
|
if (c->owned) delete c;
|
||||||
auto &host=c->app;
|
|
||||||
if (--host.windows==0) PostMessageW(host.window,ScreenshotIdle,0,0);
|
|
||||||
delete c;
|
|
||||||
}
|
|
||||||
return DefWindowProcW(w,msg,wp,lp);
|
return DefWindowProcW(w,msg,wp,lp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1509,107 +1315,27 @@ ScreenshotMark cloneScreenshotMark(const ScreenshotMark &original,const std::vec
|
|||||||
{
|
{
|
||||||
auto result=original;
|
auto result=original;
|
||||||
result.fromNumber=result.toNumber=0;
|
result.fromNumber=result.toNumber=0;
|
||||||
result.id=0; result.fromLink={}; result.toLink={};
|
|
||||||
if(result.number) result.number=nextScreenshotNumber(marks);
|
if(result.number) result.number=nextScreenshotNumber(marks);
|
||||||
auto bounds=markBounds(result);
|
auto bounds=markBounds(result);
|
||||||
LONG dx=bounds.right+16<width?16:-16;
|
LONG dx=bounds.right+16<width?16:-16;
|
||||||
LONG dy=bounds.bottom+16<height?16:-16;
|
LONG dy=bounds.bottom+16<height?16:-16;
|
||||||
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
|
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
|
||||||
}
|
}
|
||||||
namespace {
|
|
||||||
void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
|
||||||
{
|
|
||||||
static unsigned long long next=1;
|
|
||||||
for(const auto &m:marks) next=std::max(next,m.id+1);
|
|
||||||
std::unordered_set<unsigned long long> used;
|
|
||||||
for(auto &m:marks) {
|
|
||||||
if(!m.id || !used.insert(m.id).second) { m.id=next++; used.insert(m.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link)
|
|
||||||
{
|
|
||||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
|
||||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
|
||||||
POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))};
|
|
||||||
return m.isText()?rotateTextPoint(m,p):p;
|
|
||||||
}
|
|
||||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
|
||||||
double along=std::clamp(link.u,0.0,1.0)*segments;
|
|
||||||
size_t i=std::min(size_t(along),segments-1); double t=along-i;
|
|
||||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
|
||||||
return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))};
|
|
||||||
}
|
|
||||||
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
|
||||||
{
|
|
||||||
link={m.id,0,0};
|
|
||||||
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
|
||||||
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
|
||||||
POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop;
|
|
||||||
double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top);
|
|
||||||
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
|
||||||
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
|
||||||
if(m.shape==ScreenshotShape::Circle || m.shape==ScreenshotShape::Ellipse) {
|
|
||||||
double dx=2*x-1,dy=2*y-1,length=std::hypot(dx,dy);
|
|
||||||
inside=length<=1;
|
|
||||||
if(length<0.001) { dx=1; dy=0; length=1; }
|
|
||||||
link.u=(1+dx/length)/2; link.v=(1+dy/length)/2;
|
|
||||||
} else {
|
|
||||||
link.u=std::clamp(x,0.0,1.0); link.v=std::clamp(y,0.0,1.0);
|
|
||||||
if(inside) {
|
|
||||||
double edges[]{x*w,(1-x)*w,y*h,(1-y)*h};
|
|
||||||
auto side=std::min_element(edges,edges+4)-edges;
|
|
||||||
if(side<2) link.u=side==0?0:1; else link.v=side==2?0:1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto anchor=objectAnchor(m,link);
|
|
||||||
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
|
||||||
}
|
|
||||||
double best=1e100;
|
|
||||||
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
|
||||||
for(size_t i=0;i<segments;++i) {
|
|
||||||
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
|
||||||
double dx=b.x-a.x,dy=b.y-a.y,length=dx*dx+dy*dy;
|
|
||||||
double t=length?std::clamp(((drop.x-a.x)*dx+(drop.y-a.y)*dy)/length,0.0,1.0):0;
|
|
||||||
double distance=std::hypot(drop.x-a.x-t*dx,drop.y-a.y-t*dy);
|
|
||||||
if(distance<best) { best=distance; link.u=(i+t)/segments; }
|
|
||||||
}
|
|
||||||
return best<=tolerance+m.thickness/2.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
||||||
std::vector<ScreenshotMark> &marks, double tolerance)
|
const std::vector<ScreenshotMark> &marks, double tolerance)
|
||||||
{
|
{
|
||||||
assignMarkIds(marks);
|
auto &link=start?arrow.fromNumber:arrow.toNumber;
|
||||||
auto &number=start?arrow.fromNumber:arrow.toNumber;
|
link=0;
|
||||||
auto &link=start?arrow.fromLink:arrow.toLink;
|
if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return;
|
||||||
number=0; link={};
|
|
||||||
if(!arrow.isLine()) return;
|
|
||||||
// Number badges are drawn above other objects.
|
|
||||||
for(auto it=marks.rbegin();it!=marks.rend();++it)
|
for(auto it=marks.rbegin();it!=marks.rend();++it)
|
||||||
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) {
|
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance)
|
||||||
number=it->number; return;
|
{
|
||||||
}
|
link=it->number;
|
||||||
auto wouldCycle=[&](unsigned long long id) {
|
break;
|
||||||
std::vector<unsigned long long> pending{id}; std::unordered_set<unsigned long long> seen;
|
|
||||||
while(!pending.empty()) {
|
|
||||||
auto current=pending.back(); pending.pop_back();
|
|
||||||
if(arrow.id && current==arrow.id) return true;
|
|
||||||
if(!current || !seen.insert(current).second) continue;
|
|
||||||
for(const auto &m:marks) if(m.id==current) {
|
|
||||||
pending.push_back(m.fromLink.target); pending.push_back(m.toLink.target); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
for(auto it=marks.rbegin();it!=marks.rend();++it) {
|
|
||||||
if(it->number || &*it==&arrow || (arrow.id && it->id==arrow.id)) continue;
|
|
||||||
ScreenshotLink candidate;
|
|
||||||
if(findObjectAnchor(*it,drop,tolerance,candidate) && !wouldCycle(it->id)) { link=candidate; return; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||||
{
|
{
|
||||||
assignMarkIds(marks);
|
|
||||||
auto badge=[&](int number) -> const ScreenshotMark * {
|
auto badge=[&](int number) -> const ScreenshotMark * {
|
||||||
if(number) for(const auto &m:marks) if(m.number==number) return &m;
|
if(number) for(const auto &m:marks) if(m.number==number) return &m;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
@@ -1619,21 +1345,9 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
|||||||
if(length<0.001) { dx=start?1:-1; dy=0; length=1; }
|
if(length<0.001) { dx=start?1:-1; dy=0; length=1; }
|
||||||
return {m.from.x+LONG(std::lround(m.radius*dx/length)),m.from.y+LONG(std::lround(m.radius*dy/length))};
|
return {m.from.x+LONG(std::lround(m.radius*dx/length)),m.from.y+LONG(std::lround(m.radius*dy/length))};
|
||||||
};
|
};
|
||||||
std::vector<int> state(marks.size());
|
for(auto &m:marks)
|
||||||
std::function<void(size_t)> resolve=[&](size_t index) {
|
{
|
||||||
if(state[index]) return;
|
if(m.number || m.isHighlight() || m.isText() || m.shape!=ScreenshotShape::None) continue;
|
||||||
state[index]=1;
|
|
||||||
auto &m=marks[index];
|
|
||||||
if(m.isLine()) {
|
|
||||||
auto attach=[&](ScreenshotLink &link,POINT &point) {
|
|
||||||
if(!link.target) return;
|
|
||||||
for(size_t i=0;i<marks.size();++i) if(marks[i].id==link.target) {
|
|
||||||
if(state[i]==1) { link={}; return; } // Reject cyclic dependencies.
|
|
||||||
resolve(i); point=objectAnchor(marks[i],link); return;
|
|
||||||
}
|
|
||||||
link={}; // Deleted targets leave endpoints at their last position.
|
|
||||||
};
|
|
||||||
attach(m.fromLink,m.from); attach(m.toLink,m.to);
|
|
||||||
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
||||||
if(!from) m.fromNumber=0;
|
if(!from) m.fromNumber=0;
|
||||||
if(!to) m.toNumber=0;
|
if(!to) m.toNumber=0;
|
||||||
@@ -1642,9 +1356,6 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
|||||||
if(to) m.to=rim(*to,start,false);
|
if(to) m.to=rim(*to,start,false);
|
||||||
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
||||||
}
|
}
|
||||||
state[index]=2;
|
|
||||||
};
|
|
||||||
for(size_t i=0;i<marks.size();++i) resolve(i);
|
|
||||||
}
|
}
|
||||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||||
{
|
{
|
||||||
@@ -1712,7 +1423,6 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
POINT position, int width, int height, bool snapRotation)
|
POINT position, int width, int height, bool snapRotation)
|
||||||
{
|
{
|
||||||
auto m=original;
|
auto m=original;
|
||||||
if(position.x==origin.x && position.y==origin.y) return m;
|
|
||||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||||
if(position.x==origin.x && position.y==origin.y) return m;
|
if(position.x==origin.x && position.y==origin.y) return m;
|
||||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||||
@@ -1748,7 +1458,6 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
else if(part==MarkPart::Body)
|
else if(part==MarkPart::Body)
|
||||||
{
|
{
|
||||||
m.fromNumber=m.toNumber=0;
|
m.fromNumber=m.toNumber=0;
|
||||||
m.fromLink={}; m.toLink={};
|
|
||||||
auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
|
auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
|
||||||
LONG low=-first,high=LONG(extent-1)-last;
|
LONG low=-first,high=LONG(extent-1)-last;
|
||||||
if(low>high && m.isText()) {
|
if(low>high && m.isText()) {
|
||||||
@@ -1768,7 +1477,6 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||||
{
|
{
|
||||||
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
||||||
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
|
||||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
||||||
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
||||||
@@ -1790,25 +1498,10 @@ Clip screenshotClip(HDC source, int width, int height, const std::vector<Screens
|
|||||||
memcpy(bytes.data()+sizeof(header),output.pixels,bytes.size()-sizeof(header));
|
memcpy(bytes.data()+sizeof(header),output.pixels,bytes.size()-sizeof(header));
|
||||||
return Clip{{{CF_DIB,L"",std::move(bytes)}}};
|
return Clip{{{CF_DIB,L"",std::move(bytes)}}};
|
||||||
}
|
}
|
||||||
void showScreenshotEditor(ScreenshotHost &app,const Clip &clip)
|
void showScreenshot(App &app)
|
||||||
{
|
|
||||||
auto pixels=decodeImage(clip);
|
|
||||||
auto c=std::make_unique<Capture>(app);
|
|
||||||
c->image=std::make_unique<Surface>(pixels.width,pixels.height);
|
|
||||||
memcpy(c->image->pixels,pixels.bgra.data(),pixels.bgra.size());
|
|
||||||
c->start={0,0}; c->end={(LONG)pixels.width,(LONG)pixels.height}; c->copied=true;
|
|
||||||
WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=captureProc; cls.lpszClassName=L"KSwitcher.Capture";
|
|
||||||
cls.style=CS_DBLCLKS; cls.hCursor=LoadCursorW(nullptr,IDC_CROSS);
|
|
||||||
cls.hIcon=LoadIconW(app.instance,MAKEINTRESOURCEW(IDI_KSWITCHER)); RegisterClassW(&cls);
|
|
||||||
auto window=CreateWindowExW(0,cls.lpszClassName,L"KSwitcher Screenshot",WS_POPUP,0,0,1,1,
|
|
||||||
app.window,nullptr,app.instance,c.get());
|
|
||||||
if(!window) throw std::runtime_error("Cannot open screenshot editor");
|
|
||||||
c->owned=true; ++app.windows;
|
|
||||||
auto state=c.release(); edit(window,*state);
|
|
||||||
}
|
|
||||||
void showScreenshot(ScreenshotHost &app)
|
|
||||||
{
|
{
|
||||||
if (activeCapture) { showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); return; }
|
if (activeCapture) { showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); return; }
|
||||||
|
app.resetInput();
|
||||||
auto c=std::make_unique<Capture>(app);
|
auto c=std::make_unique<Capture>(app);
|
||||||
int x=GetSystemMetrics(SM_XVIRTUALSCREEN),y=GetSystemMetrics(SM_YVIRTUALSCREEN);
|
int x=GetSystemMetrics(SM_XVIRTUALSCREEN),y=GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||||
int width=GetSystemMetrics(SM_CXVIRTUALSCREEN),height=GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
int width=GetSystemMetrics(SM_CXVIRTUALSCREEN),height=GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||||
@@ -1835,7 +1528,6 @@ void showScreenshot(ScreenshotHost &app)
|
|||||||
WS_POPUP,x,y,width,height,app.window,nullptr,app.instance,c.get());
|
WS_POPUP,x,y,width,height,app.window,nullptr,app.instance,c.get());
|
||||||
if (!activeCapture) throw std::runtime_error("Cannot open screen capture");
|
if (!activeCapture) throw std::runtime_error("Cannot open screen capture");
|
||||||
c->owned=true;
|
c->owned=true;
|
||||||
++app.windows;
|
|
||||||
c.release();
|
c.release();
|
||||||
showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); SetFocus(activeCapture);
|
showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); SetFocus(activeCapture);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-18
@@ -2,25 +2,10 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
// Only the on-demand screenshot process owns this state; no resident App dependency.
|
|
||||||
struct ScreenshotHost {
|
|
||||||
HINSTANCE instance{};
|
|
||||||
HWND window{};
|
|
||||||
unsigned windows{};
|
|
||||||
bool (*notify)(const Clip &){};
|
|
||||||
bool autoOpenEditor = false;
|
|
||||||
};
|
|
||||||
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
|
|
||||||
void showScreenshot(ScreenshotHost &);
|
|
||||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
|
||||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||||
enum class LineEnds { End, Start, Both, None };
|
enum class LineEnds { End, Start, Both, None };
|
||||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||||
struct ScreenshotOffset { double x, y; };
|
struct ScreenshotOffset { double x, y; };
|
||||||
struct ScreenshotLink {
|
|
||||||
unsigned long long target{};
|
|
||||||
double u{}, v{}; // Relative boundary position, or fractional position along a path.
|
|
||||||
};
|
|
||||||
struct ScreenshotMark {
|
struct ScreenshotMark {
|
||||||
POINT from, to; int number; int radius = 15; int thickness = 3;
|
POINT from, to; int number; int radius = 15; int thickness = 3;
|
||||||
int fromNumber = 0, toNumber = 0;
|
int fromNumber = 0, toNumber = 0;
|
||||||
@@ -37,8 +22,6 @@ struct ScreenshotMark {
|
|||||||
std::wstring label;
|
std::wstring label;
|
||||||
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
||||||
bool labelRotationManual=false;
|
bool labelRotationManual=false;
|
||||||
unsigned long long id{};
|
|
||||||
ScreenshotLink fromLink, toLink;
|
|
||||||
bool isText() const { return !text.empty(); }
|
bool isText() const { return !text.empty(); }
|
||||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||||
@@ -54,7 +37,7 @@ class ScreenshotHistory {
|
|||||||
};
|
};
|
||||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||||
std::vector<ScreenshotMark> &, double tolerance);
|
const std::vector<ScreenshotMark> &, double tolerance);
|
||||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
#include "screenshot_notifications.h"
|
|
||||||
#include <atomic>
|
|
||||||
#include <shlobj.h>
|
|
||||||
#include <propkey.h>
|
|
||||||
#include <propvarutil.h>
|
|
||||||
#include <wrl/client.h>
|
|
||||||
namespace ks {
|
|
||||||
namespace {
|
|
||||||
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
|
||||||
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
|
||||||
}
|
|
||||||
namespace {
|
|
||||||
using Microsoft::WRL::ComPtr;
|
|
||||||
void check(HRESULT result) {
|
|
||||||
if(FAILED(result)) throw std::runtime_error("Screenshot registration failed: " + std::to_string((unsigned long)result));
|
|
||||||
}
|
|
||||||
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
|
|
||||||
{
|
|
||||||
HKEY key{};
|
|
||||||
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
|
|
||||||
throw std::runtime_error("Cannot register screenshot notifications");
|
|
||||||
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
|
|
||||||
RegCloseKey(key);
|
|
||||||
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void registerScreenshotNotifications()
|
|
||||||
{
|
|
||||||
auto exe=(std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe").wstring();
|
|
||||||
if(!std::filesystem::exists(exe)) throw std::runtime_error("Screenshot helper is missing");
|
|
||||||
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
|
|
||||||
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
|
|
||||||
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
|
|
||||||
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
|
|
||||||
registryString(identity,L"IconUri",exe);
|
|
||||||
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
|
|
||||||
// A shortcut supplies desktop notification identity and its COM activation callback.
|
|
||||||
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
|
||||||
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
|
|
||||||
ComPtr<IPropertyStore> properties; check(link.As(&properties));
|
|
||||||
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
|
|
||||||
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
|
|
||||||
GUID activator=ActivatorId;
|
|
||||||
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
|
|
||||||
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
|
|
||||||
check(properties->Commit());
|
|
||||||
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
|
|
||||||
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
|
|
||||||
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
|
|
||||||
// Notify the shell that notification identity/activation registration changed.
|
|
||||||
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
|
||||||
}
|
|
||||||
bool validScreenshotToken(const std::wstring &id)
|
|
||||||
{
|
|
||||||
if(id.size()!=36) return false;
|
|
||||||
for(size_t i=0;i<id.size();++i) {
|
|
||||||
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
|
|
||||||
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
|
|
||||||
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
|
|
||||||
struct NotificationInput { LPCWSTR key,value; };
|
|
||||||
struct NotificationCallback : IUnknown {
|
|
||||||
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
|
|
||||||
};
|
|
||||||
class Activator final : public NotificationCallback {
|
|
||||||
std::atomic<ULONG> references{1};
|
|
||||||
DWORD thread=GetCurrentThreadId();
|
|
||||||
std::wstring target;
|
|
||||||
bool quit;
|
|
||||||
public:
|
|
||||||
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
|
||||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
|
||||||
if(!out) return E_POINTER;
|
|
||||||
*out=nullptr;
|
|
||||||
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
|
|
||||||
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
|
|
||||||
}
|
|
||||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
|
||||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
|
||||||
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
|
||||||
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
|
|
||||||
const bool update=std::wstring(args)==L"check-update";
|
|
||||||
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
|
|
||||||
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
|
|
||||||
auto command=update ? L"\""+path+L"\" --check-update" :
|
|
||||||
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
|
||||||
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
|
|
||||||
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
|
|
||||||
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
|
|
||||||
if(ok) {
|
|
||||||
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
|
|
||||||
CloseHandle(process.hThread); CloseHandle(process.hProcess);
|
|
||||||
}
|
|
||||||
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
class ActivatorFactory final : public IClassFactory {
|
|
||||||
std::atomic<ULONG> references{1};
|
|
||||||
std::wstring target;
|
|
||||||
bool quit;
|
|
||||||
public:
|
|
||||||
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
|
||||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
|
||||||
if(!out) return E_POINTER;
|
|
||||||
*out=nullptr;
|
|
||||||
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
|
|
||||||
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
|
|
||||||
}
|
|
||||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
|
||||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
|
||||||
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
|
|
||||||
if(outer) return CLASS_E_NOAGGREGATION;
|
|
||||||
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
|
|
||||||
}
|
|
||||||
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
int runScreenshotToastActivator()
|
|
||||||
{
|
|
||||||
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
|
|
||||||
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
|
|
||||||
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
|
|
||||||
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
|
||||||
factory->Release();
|
|
||||||
if(SUCCEEDED(result)) {
|
|
||||||
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
|
|
||||||
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
|
|
||||||
if(timer) KillTimer(nullptr,timer);
|
|
||||||
CoRevokeClassObject(registration);
|
|
||||||
}
|
|
||||||
CoUninitialize(); return FAILED(result)?1:0;
|
|
||||||
}
|
|
||||||
DWORD startScreenshotActivationListener()
|
|
||||||
{
|
|
||||||
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
|
||||||
auto factory=new ActivatorFactory(target.wstring(),false);
|
|
||||||
DWORD registration{};
|
|
||||||
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
|
||||||
factory->Release();
|
|
||||||
return SUCCEEDED(result)?registration:0;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#include "app.h"
|
|
||||||
|
|
||||||
namespace ks {
|
|
||||||
void launchScreenshot(App &app)
|
|
||||||
{
|
|
||||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
|
||||||
if (!std::filesystem::exists(path))
|
|
||||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
|
||||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
|
||||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
|
||||||
if(app.settings.writeLog) command+=L" --write-log";
|
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
|
||||||
PROCESS_INFORMATION process{};
|
|
||||||
// Grant foreground rights before the child's first selection window opens.
|
|
||||||
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
|
|
||||||
nullptr,path.parent_path().c_str(),&startup,&process))
|
|
||||||
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
|
|
||||||
AllowSetForegroundWindow(process.dwProcessId);
|
|
||||||
ResumeThread(process.hThread);
|
|
||||||
CloseHandle(process.hThread);
|
|
||||||
CloseHandle(process.hProcess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
#include "screenshot.h"
|
|
||||||
#include "screenshot_notifications.h"
|
|
||||||
#include <commctrl.h>
|
|
||||||
#include <shellapi.h>
|
|
||||||
#include <roapi.h>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
|
|
||||||
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
|
||||||
{
|
|
||||||
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
|
|
||||||
if(message==WM_NCCREATE) {
|
|
||||||
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
|
|
||||||
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
|
|
||||||
}
|
|
||||||
if(host && message==ks::ScreenshotRequest) {
|
|
||||||
host->autoOpenEditor=(wp&1)!=0;
|
|
||||||
ks::setLoggingEnabled((wp&2)!=0);
|
|
||||||
try { ks::showScreenshot(*host); }
|
|
||||||
catch(const std::exception &e) {
|
|
||||||
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
|
|
||||||
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
|
||||||
}
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
if(host && message==WM_COPYDATA) {
|
|
||||||
auto data=(COPYDATASTRUCT *)lp;
|
|
||||||
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
|
|
||||||
std::wstring token((const wchar_t *)data->lpData,36);
|
|
||||||
if(!ks::validScreenshotToken(token)) return FALSE;
|
|
||||||
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
|
|
||||||
catch(const std::exception &e) {
|
|
||||||
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
|
|
||||||
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
|
||||||
}
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
if(host && message==ks::ScreenshotIdle && !host->windows) {
|
|
||||||
DestroyWindow(window);
|
|
||||||
PostQuitMessage(0);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return DefWindowProcW(window,message,wp,lp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
|
||||||
{
|
|
||||||
std::wstring token;
|
|
||||||
bool autoOpenEditor=false, writeLog=false;
|
|
||||||
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
|
||||||
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
|
|
||||||
writeLog=true; --argc;
|
|
||||||
ks::setLoggingEnabled(true);
|
|
||||||
}
|
|
||||||
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
|
|
||||||
LocalFree(argv); return ks::runScreenshotToastActivator();
|
|
||||||
}
|
|
||||||
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
|
|
||||||
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
|
||||||
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
|
|
||||||
CoUninitialize(); return result;
|
|
||||||
}
|
|
||||||
if(argc==2 && (std::wstring(argv[1])==L"--auto-open-editor=0" ||
|
|
||||||
std::wstring(argv[1])==L"--auto-open-editor=1")) {
|
|
||||||
autoOpenEditor=std::wstring(argv[1])==L"--auto-open-editor=1";
|
|
||||||
} else if(argc>1) {
|
|
||||||
if(argc==3 && std::wstring(argv[1])==L"--open") {
|
|
||||||
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
|
|
||||||
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
|
|
||||||
}
|
|
||||||
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
|
|
||||||
}
|
|
||||||
LocalFree(argv);
|
|
||||||
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
|
|
||||||
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
|
|
||||||
if(!mutex) return 1;
|
|
||||||
// Wait through startup/shutdown races; the controller accepts requests only while alive.
|
|
||||||
bool acquired=false;
|
|
||||||
for(int attempt=0;attempt<100;++attempt) {
|
|
||||||
auto wait=WaitForSingleObject(mutex,0);
|
|
||||||
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
|
|
||||||
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
|
|
||||||
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
|
|
||||||
AllowSetForegroundWindow(pid);
|
|
||||||
DWORD_PTR accepted{};
|
|
||||||
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,token.empty()?(autoOpenEditor?1:0)|(writeLog?2:0):0,
|
|
||||||
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
|
|
||||||
CloseHandle(mutex); return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Sleep(50);
|
|
||||||
}
|
|
||||||
if(!acquired) { CloseHandle(mutex); return 1; }
|
|
||||||
RoInitialize(RO_INIT_SINGLETHREADED);
|
|
||||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
|
|
||||||
InitCommonControlsEx(&controls);
|
|
||||||
ks::ScreenshotHost host{instance};
|
|
||||||
host.notify=ks::notifyScreenshot;
|
|
||||||
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
|
|
||||||
RegisterClassW(&cls);
|
|
||||||
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
|
|
||||||
int result=1;
|
|
||||||
if(host.window) {
|
|
||||||
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
|
|
||||||
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
|
|
||||||
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
|
|
||||||
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
|
|
||||||
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
|
|
||||||
MSG message{};
|
|
||||||
while((result=GetMessageW(&message,nullptr,0,0))>0) {
|
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
|
||||||
}
|
|
||||||
result=result<0?1:0;
|
|
||||||
}
|
|
||||||
RoUninitialize();
|
|
||||||
ReleaseMutex(mutex); CloseHandle(mutex);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
#include "screenshot_notifications.h"
|
|
||||||
#include "ocr.h"
|
|
||||||
#include <algorithm>
|
|
||||||
#include <chrono>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <aclapi.h>
|
|
||||||
#include <sddl.h>
|
|
||||||
#include <shlobj.h>
|
|
||||||
#include <propkey.h>
|
|
||||||
#include <propvarutil.h>
|
|
||||||
#include <roapi.h>
|
|
||||||
#include <winstring.h>
|
|
||||||
#include <wincodec.h>
|
|
||||||
#ifdef __MINGW32__
|
|
||||||
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
|
|
||||||
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
|
|
||||||
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
|
|
||||||
#endif
|
|
||||||
#include <windows.ui.notifications.h>
|
|
||||||
#include <windows.data.xml.dom.h>
|
|
||||||
#include <wrl/client.h>
|
|
||||||
|
|
||||||
namespace ks {
|
|
||||||
namespace {
|
|
||||||
using Microsoft::WRL::ComPtr;
|
|
||||||
namespace Notifications=ABI::Windows::UI::Notifications;
|
|
||||||
namespace Xml=ABI::Windows::Data::Xml::Dom;
|
|
||||||
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
|
||||||
std::string notificationError;
|
|
||||||
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
|
|
||||||
struct String {
|
|
||||||
HSTRING value{};
|
|
||||||
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
|
|
||||||
~String() { WindowsDeleteString(value); }
|
|
||||||
};
|
|
||||||
std::filesystem::path cacheDirectory()
|
|
||||||
{
|
|
||||||
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
|
|
||||||
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
|
|
||||||
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
|
|
||||||
}
|
|
||||||
std::filesystem::path previewDirectory()
|
|
||||||
{
|
|
||||||
HANDLE token{};
|
|
||||||
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
|
|
||||||
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
|
|
||||||
std::vector<BYTE> data(size);
|
|
||||||
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
|
|
||||||
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
|
|
||||||
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
|
|
||||||
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
|
|
||||||
std::wstring sid(sidText); LocalFree(sidText);
|
|
||||||
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
|
|
||||||
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
|
|
||||||
// The notification broker cannot load previews from some profile directories.
|
|
||||||
// Keep this cache outside the profile, private to the current user and SYSTEM.
|
|
||||||
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
|
|
||||||
PSECURITY_DESCRIPTOR security{};
|
|
||||||
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
|
|
||||||
check(HRESULT_FROM_WIN32(GetLastError()));
|
|
||||||
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
|
|
||||||
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
|
|
||||||
error=created?ERROR_SUCCESS:GetLastError();
|
|
||||||
LocalFree(security);
|
|
||||||
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
|
|
||||||
// Do not use a pre-created directory or junction controlled by another user.
|
|
||||||
DWORD flags=GetFileAttributesW(path.c_str());
|
|
||||||
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
|
|
||||||
throw std::runtime_error("Invalid screenshot preview directory");
|
|
||||||
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
|
|
||||||
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
|
|
||||||
&owner,nullptr,nullptr,nullptr,&existing);
|
|
||||||
check(HRESULT_FROM_WIN32(error));
|
|
||||||
bool owned=EqualSid(owner,user); LocalFree(existing);
|
|
||||||
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
void cleanCache(const std::filesystem::path &path)
|
|
||||||
{
|
|
||||||
std::vector<std::filesystem::directory_entry> files;
|
|
||||||
for(const auto &file:std::filesystem::directory_iterator(path))
|
|
||||||
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
|
|
||||||
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
|
|
||||||
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
|
|
||||||
uintmax_t size=0;
|
|
||||||
for(size_t i=0;i<files.size();++i) {
|
|
||||||
size+=files[i].file_size();
|
|
||||||
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
|
|
||||||
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void savePreview(const Clip &clip,const std::filesystem::path &path)
|
|
||||||
{
|
|
||||||
auto pixels=decodeImage(clip,360,220);
|
|
||||||
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
|
||||||
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
|
|
||||||
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
|
|
||||||
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
|
|
||||||
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
|
|
||||||
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
|
|
||||||
check(frame->SetSize(pixels.width,pixels.height));
|
|
||||||
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
|
|
||||||
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
|
|
||||||
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
|
|
||||||
check(frame->Commit()); check(encoder->Commit());
|
|
||||||
}
|
|
||||||
std::wstring escapeXml(const std::wstring &s)
|
|
||||||
{
|
|
||||||
std::wstring result;
|
|
||||||
for(auto ch:s) { if(ch==L'&') result+=L"&"; else if(ch==L'\"') result+=L"""; else if(ch==L'<') result+=L"<"; else result+=ch; }
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
|
||||||
{
|
|
||||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
|
||||||
auto directory=cacheDirectory(); cleanCache(directory);
|
|
||||||
auto path=directory/(id+L".capture");
|
|
||||||
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
|
|
||||||
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
|
|
||||||
}
|
|
||||||
bool notifyScreenshot(const Clip &clip)
|
|
||||||
{
|
|
||||||
notificationError.clear();
|
|
||||||
const char *stage="registration";
|
|
||||||
try {
|
|
||||||
registerScreenshotNotifications();
|
|
||||||
check(SetCurrentProcessExplicitAppUserModelID(AppId));
|
|
||||||
stage="notification manager";
|
|
||||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
|
||||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
|
||||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
|
||||||
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
|
||||||
stage="notifier";
|
|
||||||
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
|
||||||
stage="settings";
|
|
||||||
Notifications::NotificationSetting setting{};
|
|
||||||
auto settingResult=notifier->get_Setting(&setting);
|
|
||||||
// A first-time sender has no settings record until its first notification.
|
|
||||||
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
|
|
||||||
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
|
|
||||||
stage="cache";
|
|
||||||
auto directory=cacheDirectory(); cleanCache(directory);
|
|
||||||
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
|
||||||
std::wstring id(value+1,36);
|
|
||||||
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
|
|
||||||
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
|
|
||||||
atomicWrite(directory/(id+L".capture"),protect(dib->data));
|
|
||||||
stage="preview";
|
|
||||||
auto previews=previewDirectory(); cleanCache(previews);
|
|
||||||
auto preview=previews/(id+L".png"); savePreview(clip,preview);
|
|
||||||
cleanCache(previews);
|
|
||||||
cleanCache(directory);
|
|
||||||
stage="XML";
|
|
||||||
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
|
|
||||||
check(RoActivateInstance(documentClass.value,&documentInstance));
|
|
||||||
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
|
|
||||||
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
|
|
||||||
auto uri=L"file:///"+preview.generic_wstring();
|
|
||||||
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
|
|
||||||
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
|
|
||||||
check(documentIO->LoadXml(content.value));
|
|
||||||
stage="toast factory";
|
|
||||||
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
|
||||||
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
|
|
||||||
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
|
|
||||||
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
|
|
||||||
String propertyClass(L"Windows.Foundation.PropertyValue");
|
|
||||||
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
|
|
||||||
FILETIME now{}; GetSystemTimeAsFileTime(&now);
|
|
||||||
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
|
|
||||||
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
|
|
||||||
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
|
|
||||||
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
|
|
||||||
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
|
|
||||||
stage="show";
|
|
||||||
check(notifier->Show(toast.Get()));
|
|
||||||
return true;
|
|
||||||
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
|
|
||||||
}
|
|
||||||
std::string screenshotNotificationError() { return notificationError; }
|
|
||||||
|
|
||||||
std::wstring updateToastXml(const std::wstring &version)
|
|
||||||
{
|
|
||||||
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
|
|
||||||
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
|
|
||||||
L" is available. Click to review and install.</text></binding></visual>"
|
|
||||||
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
|
|
||||||
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
|
|
||||||
}
|
|
||||||
bool notifyUpdateAvailable(const std::wstring &version)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
|
||||||
registerScreenshotNotifications();
|
|
||||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
|
||||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
|
||||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
|
||||||
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
|
||||||
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
|
||||||
Notifications::NotificationSetting setting{};
|
|
||||||
auto settingsResult=notifier->get_Setting(&setting);
|
|
||||||
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
|
|
||||||
if(setting!=Notifications::NotificationSetting_Enabled)
|
|
||||||
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
|
|
||||||
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
|
|
||||||
check(RoActivateInstance(documentClass.value,&instance));
|
|
||||||
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
|
|
||||||
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
|
|
||||||
String content(updateToastXml(version)); check(io->LoadXml(content.value));
|
|
||||||
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
|
||||||
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
|
|
||||||
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
|
|
||||||
// Repeated submissions replace the existing update card in Notification Center.
|
|
||||||
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
|
|
||||||
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
|
|
||||||
check(notifier->Show(toast.Get()));
|
|
||||||
return true;
|
|
||||||
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "platform.h"
|
|
||||||
namespace ks {
|
|
||||||
bool notifyScreenshot(const Clip &);
|
|
||||||
std::string screenshotNotificationError();
|
|
||||||
void registerScreenshotNotifications();
|
|
||||||
int runScreenshotToastActivator();
|
|
||||||
DWORD startScreenshotActivationListener();
|
|
||||||
bool validScreenshotToken(const std::wstring &);
|
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
|
||||||
}
|
|
||||||
+4
-101
@@ -44,13 +44,6 @@ bool validId(const std::wstring &id)
|
|||||||
} // namespace
|
} // namespace
|
||||||
void validateSettings(const Settings &s)
|
void validateSettings(const Settings &s)
|
||||||
{
|
{
|
||||||
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
|
|
||||||
throw std::runtime_error("Invalid Tesseract executable path");
|
|
||||||
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
|
|
||||||
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
|
|
||||||
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
|
|
||||||
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
|
|
||||||
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
|
|
||||||
std::vector<Hotkey> used;
|
std::vector<Hotkey> used;
|
||||||
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
|
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
|
||||||
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
|
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
|
||||||
@@ -92,17 +85,6 @@ void validateSettings(const Settings &s)
|
|||||||
throw std::runtime_error("Unsupported auto-correction language");
|
throw std::runtime_error("Unsupported auto-correction language");
|
||||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||||
throw std::runtime_error("Too many auto-correction exceptions");
|
throw std::runtime_error("Too many auto-correction exceptions");
|
||||||
for (const auto &[language, words] : s.autoWords)
|
|
||||||
{
|
|
||||||
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
|
||||||
language == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" :
|
|
||||||
language == L"ru" ? L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя" : L"";
|
|
||||||
if (alphabet.empty() || words.size() > 10000)
|
|
||||||
throw std::runtime_error("Invalid personal dictionary language or too many words");
|
|
||||||
for (const auto &word : words)
|
|
||||||
if (word.empty() || word.size() > 32 || lower(word).find_first_not_of(alphabet) != word.npos)
|
|
||||||
throw std::runtime_error("Personal words must contain 1-32 letters of their selected language");
|
|
||||||
}
|
|
||||||
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
|
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
|
||||||
s.maxImageMiB > 64)
|
s.maxImageMiB > 64)
|
||||||
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
||||||
@@ -143,9 +125,6 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.switchLayout = j.value("switchSystemLayout", true);
|
s.switchLayout = j.value("switchSystemLayout", true);
|
||||||
s.writeLog = j.value("writeLog", true);
|
s.writeLog = j.value("writeLog", true);
|
||||||
s.history = j.value("historyEnabled", true);
|
s.history = j.value("historyEnabled", true);
|
||||||
s.useTesseract = j.value("useTesseract", false);
|
|
||||||
s.tesseractPath = str(j, "tesseractPath");
|
|
||||||
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
|
|
||||||
s.autostart = j.value("startWithSystem", false);
|
s.autostart = j.value("startWithSystem", false);
|
||||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||||
s.awake = j.value("preventScreenLock", false);
|
s.awake = j.value("preventScreenLock", false);
|
||||||
@@ -154,10 +133,6 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||||
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
|
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
|
||||||
if (j.contains("autoCorrectWords"))
|
|
||||||
for (auto &[language, words] : j.at("autoCorrectWords").items())
|
|
||||||
for (auto &word : words)
|
|
||||||
s.autoWords[wide(language)].push_back(lower(wide(word.get<std::string>())));
|
|
||||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||||
{"autoCorrectNever", &s.autoNever},
|
{"autoCorrectNever", &s.autoNever},
|
||||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||||
@@ -176,7 +151,6 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.undo = str(j, "undoHotkey", s.undo);
|
s.undo = str(j, "undoHotkey", s.undo);
|
||||||
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
||||||
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
||||||
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
|
|
||||||
if (j.contains("layouts"))
|
if (j.contains("layouts"))
|
||||||
{
|
{
|
||||||
s.cycle.clear();
|
s.cycle.clear();
|
||||||
@@ -221,9 +195,6 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"writeLog", s.writeLog},
|
{"writeLog", s.writeLog},
|
||||||
{"autostartAsAdministrator", s.elevatedAutostart},
|
{"autostartAsAdministrator", s.elevatedAutostart},
|
||||||
{"historyEnabled", s.history},
|
{"historyEnabled", s.history},
|
||||||
{"useTesseract", s.useTesseract},
|
|
||||||
{"tesseractPath", utf8(s.tesseractPath)},
|
|
||||||
{"tesseractLanguages", utf8(s.tesseractLanguages)},
|
|
||||||
{"startWithSystem", s.autostart},
|
{"startWithSystem", s.autostart},
|
||||||
{"preventScreenLock", s.awake},
|
{"preventScreenLock", s.awake},
|
||||||
{"maxItems", s.maxItems},
|
{"maxItems", s.maxItems},
|
||||||
@@ -234,8 +205,7 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"translitHotkey", utf8(s.translit)},
|
{"translitHotkey", utf8(s.translit)},
|
||||||
{"undoHotkey", utf8(s.undo)},
|
{"undoHotkey", utf8(s.undo)},
|
||||||
{"clipboardHotkey", utf8(s.clipboard)},
|
{"clipboardHotkey", utf8(s.clipboard)},
|
||||||
{"screenshotHotkey", utf8(s.screenshot)},
|
{"screenshotHotkey", utf8(s.screenshot)}};
|
||||||
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
|
||||||
j["layouts"] = Json::array();
|
j["layouts"] = Json::array();
|
||||||
for (auto &id : s.cycle)
|
for (auto &id : s.cycle)
|
||||||
j["layouts"].push_back(utf8(id));
|
j["layouts"].push_back(utf8(id));
|
||||||
@@ -244,13 +214,6 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
||||||
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
|
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
|
||||||
j["autoCorrectWords"] = Json::object();
|
|
||||||
for (const auto &[language, words] : s.autoWords)
|
|
||||||
{
|
|
||||||
auto &items = j["autoCorrectWords"][utf8(language)] = Json::array();
|
|
||||||
for (const auto &word : words)
|
|
||||||
items.push_back(utf8(lower(word)));
|
|
||||||
}
|
|
||||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||||
{"autoCorrectNever", &s.autoNever},
|
{"autoCorrectNever", &s.autoNever},
|
||||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||||
@@ -332,8 +295,7 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
|
|||||||
e.value("at", uint64_t{}),
|
e.value("at", uint64_t{}),
|
||||||
e.value("pinned", false),
|
e.value("pinned", false),
|
||||||
e.value("image", false),
|
e.value("image", false),
|
||||||
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
|
e.value("files", false)};
|
||||||
str(e, "ocrProfile", L"windows")};
|
|
||||||
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
|
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
|
||||||
entries_.push_back(std::move(item));
|
entries_.push_back(std::move(item));
|
||||||
}
|
}
|
||||||
@@ -349,7 +311,7 @@ void History::saveIndex()
|
|||||||
{"at", e.at},
|
{"at", e.at},
|
||||||
{"pinned", e.pinned},
|
{"pinned", e.pinned},
|
||||||
{"image", e.image},
|
{"image", e.image},
|
||||||
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
|
{"files", e.files}});
|
||||||
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
|
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
|
||||||
}
|
}
|
||||||
std::vector<Entry> History::entries() const
|
std::vector<Entry> History::entries() const
|
||||||
@@ -378,7 +340,6 @@ void History::prune(const Settings &s)
|
|||||||
total -= entries_[i].bytes;
|
total -= entries_[i].bytes;
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
|
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
|
||||||
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
|
|
||||||
entries_.erase(entries_.begin() + i);
|
entries_.erase(entries_.begin() + i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,8 +427,7 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
|||||||
GetSystemTimeAsFileTime(&now);
|
GetSystemTimeAsFileTime(&now);
|
||||||
entries_.insert(entries_.begin(),
|
entries_.insert(entries_.begin(),
|
||||||
Entry{id, text, app, encrypted.size(),
|
Entry{id, text, app, encrypted.size(),
|
||||||
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
|
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files});
|
||||||
image ? 1 : 0, ocrProfile(s)});
|
|
||||||
prune(s);
|
prune(s);
|
||||||
saveIndex();
|
saveIndex();
|
||||||
}
|
}
|
||||||
@@ -483,7 +443,6 @@ void History::remove(const std::wstring &id)
|
|||||||
saveIndex();
|
saveIndex();
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(folder_ / (id + L".dat"), ec);
|
std::filesystem::remove(folder_ / (id + L".dat"), ec);
|
||||||
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
|
|
||||||
}
|
}
|
||||||
void History::pin(const std::wstring &id)
|
void History::pin(const std::wstring &id)
|
||||||
{
|
{
|
||||||
@@ -503,49 +462,8 @@ void History::clear()
|
|||||||
{
|
{
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
|
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
|
||||||
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mutex_);
|
|
||||||
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
|
|
||||||
// A late OCR completion must never recreate a deleted/pruned image.
|
|
||||||
auto profile = ocrProfile(settings);
|
|
||||||
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
|
|
||||||
if (text)
|
|
||||||
{
|
|
||||||
auto utf = utf8(*text);
|
|
||||||
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
|
|
||||||
// A version byte also makes empty recognition results encryptable by DPAPI.
|
|
||||||
Bytes payload{1};
|
|
||||||
payload.insert(payload.end(), utf.begin(), utf.end());
|
|
||||||
auto encrypted = protect(payload);
|
|
||||||
auto path = folder_ / (id + L".ocr");
|
|
||||||
std::error_code ec;
|
|
||||||
auto previousBytes = std::filesystem::file_size(path, ec);
|
|
||||||
if (ec) previousBytes = 0;
|
|
||||||
atomicWrite(path, encrypted);
|
|
||||||
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
|
|
||||||
it->ocrState = 2;
|
|
||||||
}
|
|
||||||
else it->ocrState = 3;
|
|
||||||
it->ocrProfile = profile;
|
|
||||||
prune(settings);
|
|
||||||
saveIndex();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mutex_);
|
|
||||||
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
|
|
||||||
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
|
|
||||||
}
|
|
||||||
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
|
|
||||||
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
|
|
||||||
return wide(std::string(bytes.begin() + 1, bytes.end()));
|
|
||||||
}
|
|
||||||
std::vector<Entry> History::search(const std::wstring &query) const
|
std::vector<Entry> History::search(const std::wstring &query) const
|
||||||
{
|
{
|
||||||
auto items = entries();
|
auto items = entries();
|
||||||
@@ -557,21 +475,6 @@ std::vector<Entry> History::search(const std::wstring &query) const
|
|||||||
{
|
{
|
||||||
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
|
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
|
||||||
return false;
|
return false;
|
||||||
if (e.ocrState == 2)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
auto text = ocrText(e.id);
|
|
||||||
if (text && lower(*text).find(q) != q.npos) return false;
|
|
||||||
}
|
|
||||||
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
|
|
||||||
}
|
|
||||||
// Short previews contain the complete text (or a placeholder for
|
|
||||||
// textless clips). Do not decrypt image payloads just to rule them out.
|
|
||||||
// Newlines/tabs are flattened in previews, so those queries still
|
|
||||||
// need the original text. Exactly 240 characters may be truncated.
|
|
||||||
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
|
|
||||||
return true;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return lower(load(e.id).text()).find(q) == q.npos;
|
return lower(load(e.id).text()).find(q) == q.npos;
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ struct Entry
|
|||||||
std::wstring id, preview, app;
|
std::wstring id, preview, app;
|
||||||
uint64_t bytes{}, at{};
|
uint64_t bytes{}, at{};
|
||||||
bool pinned{}, image{}, files{};
|
bool pinned{}, image{}, files{};
|
||||||
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
|
|
||||||
std::wstring ocrProfile = L"windows";
|
|
||||||
};
|
};
|
||||||
class History
|
class History
|
||||||
{
|
{
|
||||||
@@ -29,8 +27,6 @@ class History
|
|||||||
std::vector<Entry> entries() const;
|
std::vector<Entry> entries() const;
|
||||||
Clip load(const std::wstring &id) const;
|
Clip load(const std::wstring &id) const;
|
||||||
void add(const Clip &, const std::wstring &app, const Settings &);
|
void add(const Clip &, const std::wstring &app, const Settings &);
|
||||||
bool saveOcr(const std::wstring &id, const std::optional<std::wstring> &, const Settings &);
|
|
||||||
std::optional<std::wstring> ocrText(const std::wstring &id, const std::wstring &profile = L"") const;
|
|
||||||
void remove(const std::wstring &);
|
void remove(const std::wstring &);
|
||||||
void pin(const std::wstring &);
|
void pin(const std::wstring &);
|
||||||
void clear();
|
void clear();
|
||||||
|
|||||||
+282
-247
@@ -4,7 +4,6 @@
|
|||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
#include "ui_geometry.h"
|
#include "ui_geometry.h"
|
||||||
#include "diagnostics.h"
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <commdlg.h>
|
#include <commdlg.h>
|
||||||
@@ -32,11 +31,9 @@ HFONT font(HWND w)
|
|||||||
struct WindowLayout
|
struct WindowLayout
|
||||||
{
|
{
|
||||||
int width{}, height{}, x{}, y{};
|
int width{}, height{}, x{}, y{};
|
||||||
bool scrolling{}, arranging{}, history{};
|
bool scrolling{}, arranging{};
|
||||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||||
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
|
||||||
bool autoOptions{};
|
|
||||||
};
|
};
|
||||||
WindowLayout *windowLayoutState(HWND w)
|
WindowLayout *windowLayoutState(HWND w)
|
||||||
{
|
{
|
||||||
@@ -71,86 +68,11 @@ void arrangeWindow(HWND w)
|
|||||||
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
||||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||||
}
|
}
|
||||||
RECT client{}; GetClientRect(w, &client);
|
|
||||||
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
|
||||||
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
|
||||||
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
|
||||||
struct Move { HWND child; RECT rect; };
|
|
||||||
std::vector<Move> moves;
|
|
||||||
for (const auto &[child, r] : layout->controls)
|
for (const auto &[child, r] : layout->controls)
|
||||||
{
|
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
|
||||||
int id = GetDlgCtrlID(child);
|
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
|
||||||
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);
|
|
||||||
if (child == layout->stretch)
|
|
||||||
{
|
|
||||||
next.right += extraWidth;
|
|
||||||
next.bottom += extraHeight;
|
|
||||||
}
|
|
||||||
if (child == layout->details)
|
|
||||||
{
|
|
||||||
next.right += extraWidth;
|
|
||||||
OffsetRect(&next, 0, extraHeight);
|
|
||||||
}
|
|
||||||
if (child == layout->bottomLeft || child == layout->bottomRight)
|
|
||||||
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
|
||||||
if (layout->autoOptions)
|
|
||||||
{
|
|
||||||
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
|
|
||||||
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
|
|
||||||
{
|
|
||||||
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
|
|
||||||
int leftExtra = extraWidth * column / 3;
|
|
||||||
OffsetRect(&next, leftExtra, 0);
|
|
||||||
next.right += extraWidth * (column + 1) / 3 - leftExtra;
|
|
||||||
if (r.top == 328) next.bottom += extraHeight;
|
|
||||||
}
|
|
||||||
else if (r.top == 474 || r.top == 500)
|
|
||||||
{
|
|
||||||
bool rightColumn = r.left >= 290;
|
|
||||||
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
|
|
||||||
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
|
|
||||||
}
|
|
||||||
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
|
|
||||||
OffsetRect(&next, extraWidth, 0);
|
|
||||||
else if (r.right - r.left >= 500)
|
|
||||||
next.right += extraWidth;
|
|
||||||
}
|
|
||||||
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;
|
layout->arranging = false;
|
||||||
}
|
InvalidateRect(w, nullptr, TRUE);
|
||||||
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
|
|
||||||
{
|
|
||||||
SCROLLINFO info{sizeof(info), SIF_RANGE | SIF_PAGE};
|
|
||||||
GetScrollInfo(w, horizontal ? SB_HORZ : SB_VERT, &info);
|
|
||||||
int next = std::clamp(requested, info.nMin, std::max(info.nMin, info.nMax - std::max(0, (int)info.nPage - 1)));
|
|
||||||
int &position = horizontal ? layout->x : layout->y;
|
|
||||||
if (position == next) return;
|
|
||||||
position = next;
|
|
||||||
arrangeWindow(w);
|
|
||||||
}
|
}
|
||||||
void revealControl(HWND child)
|
void revealControl(HWND child)
|
||||||
{
|
{
|
||||||
@@ -196,7 +118,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
|||||||
bool vertical = msg == WM_VSCROLL;
|
bool vertical = msg == WM_VSCROLL;
|
||||||
SCROLLINFO info{sizeof(info), SIF_ALL};
|
SCROLLINFO info{sizeof(info), SIF_ALL};
|
||||||
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
|
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
|
||||||
int position = vertical ? layout->y : layout->x;
|
int &position = vertical ? layout->y : layout->x;
|
||||||
switch (LOWORD(wp))
|
switch (LOWORD(wp))
|
||||||
{
|
{
|
||||||
case SB_LINEUP: position -= scale(w, 32); break;
|
case SB_LINEUP: position -= scale(w, 32); break;
|
||||||
@@ -207,7 +129,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
|||||||
case SB_BOTTOM: position = info.nMax; break;
|
case SB_BOTTOM: position = info.nMax; break;
|
||||||
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
|
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
|
||||||
}
|
}
|
||||||
scrollWindow(w, layout, !vertical, position);
|
arrangeWindow(w);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
|
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
|
||||||
@@ -217,9 +139,8 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
|||||||
remainder += GET_WHEEL_DELTA_WPARAM(wp);
|
remainder += GET_WHEEL_DELTA_WPARAM(wp);
|
||||||
int steps = remainder / WHEEL_DELTA;
|
int steps = remainder / WHEEL_DELTA;
|
||||||
remainder %= WHEEL_DELTA;
|
remainder %= WHEEL_DELTA;
|
||||||
if (steps)
|
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
|
||||||
scrollWindow(w, layout, horizontal, (horizontal ? layout->x : layout->y) +
|
arrangeWindow(w);
|
||||||
steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1));
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (msg == WM_NCDESTROY)
|
if (msg == WM_NCDESTROY)
|
||||||
@@ -317,7 +238,7 @@ std::wstring value(HWND w, int id)
|
|||||||
text.resize(n);
|
text.resize(n);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
[[maybe_unused]] void value(HWND w, int id, const std::wstring &text)
|
void value(HWND w, int id, const std::wstring &text)
|
||||||
{
|
{
|
||||||
SetDlgItemTextW(w, id, text.c_str());
|
SetDlgItemTextW(w, id, text.c_str());
|
||||||
}
|
}
|
||||||
@@ -346,7 +267,7 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
|||||||
RegisterClassW(&wc);
|
RegisterClassW(&wc);
|
||||||
UINT dpi = GetDpiForSystem();
|
UINT dpi = GetDpiForSystem();
|
||||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
|
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
|
||||||
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
||||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||||
POINT cursor{};
|
POINT cursor{};
|
||||||
@@ -430,9 +351,6 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
|||||||
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
||||||
if (old)
|
if (old)
|
||||||
DeleteObject(old);
|
DeleteObject(old);
|
||||||
// layoutProc consumes WM_DPICHANGED, so owner-drawn rows must be resized here.
|
|
||||||
if (layout && layout->history)
|
|
||||||
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
|
||||||
fitToMonitor(w, *(RECT *)lp);
|
fitToMonitor(w, *(RECT *)lp);
|
||||||
arrangeWindow(w);
|
arrangeWindow(w);
|
||||||
}
|
}
|
||||||
@@ -490,7 +408,7 @@ void search(HWND w, HistoryView *v)
|
|||||||
auto generation = ++v->generation;
|
auto generation = ++v->generation;
|
||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
auto *app = v->app;
|
auto *app = v->app;
|
||||||
app->enqueueHistorySearch(
|
app->enqueue(
|
||||||
[w, app, q, generation]
|
[w, app, q, generation]
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -695,8 +613,6 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
if (id == 101 && HIWORD(wp) == EN_CHANGE)
|
if (id == 101 && HIWORD(wp) == EN_CHANGE)
|
||||||
{
|
{
|
||||||
// Reject an older result even during the debounce interval.
|
|
||||||
++v->generation;
|
|
||||||
SetTimer(w, 1, 180, nullptr);
|
SetTimer(w, 1, 180, nullptr);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -804,7 +720,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
||||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
||||||
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
|
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
|
||||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
|
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (Windows OCR)");
|
||||||
POINT p;
|
POINT p;
|
||||||
GetCursorPos(&p);
|
GetCursorPos(&p);
|
||||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
||||||
@@ -822,6 +738,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
++v->generation;
|
++v->generation;
|
||||||
return 0;
|
return 0;
|
||||||
|
case WM_DPICHANGED:
|
||||||
|
{
|
||||||
|
resizeForDpi(w, wp, lp);
|
||||||
|
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
case WM_NCDESTROY:
|
case WM_NCDESTROY:
|
||||||
disposeFont(w);
|
disposeFont(w);
|
||||||
v->app->historyWindow = nullptr;
|
v->app->historyWindow = nullptr;
|
||||||
@@ -891,7 +813,6 @@ void fillSettings(HWND w, SettingsView *v)
|
|||||||
check(w, 201, s.recent);
|
check(w, 201, s.recent);
|
||||||
check(w, 202, s.switchLayout);
|
check(w, 202, s.switchLayout);
|
||||||
check(w, 208, s.writeLog);
|
check(w, 208, s.writeLog);
|
||||||
check(w, 209, s.screenshotAutoOpenEditor);
|
|
||||||
check(w, 203, s.history);
|
check(w, 203, s.history);
|
||||||
check(w, 204, s.autostart);
|
check(w, 204, s.autostart);
|
||||||
check(w, 206, s.elevatedAutostart);
|
check(w, 206, s.elevatedAutostart);
|
||||||
@@ -942,7 +863,6 @@ Settings collectSettings(HWND w, SettingsView *v)
|
|||||||
s.recent = checked(w, 201);
|
s.recent = checked(w, 201);
|
||||||
s.switchLayout = checked(w, 202);
|
s.switchLayout = checked(w, 202);
|
||||||
s.writeLog = checked(w, 208);
|
s.writeLog = checked(w, 208);
|
||||||
s.screenshotAutoOpenEditor = checked(w, 209);
|
|
||||||
s.history = checked(w, 203);
|
s.history = checked(w, 203);
|
||||||
s.autostart = checked(w, 204);
|
s.autostart = checked(w, 204);
|
||||||
s.elevatedAutostart = checked(w, 206);
|
s.elevatedAutostart = checked(w, 206);
|
||||||
@@ -1047,15 +967,6 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (checked(w, 403))
|
if (checked(w, 403))
|
||||||
draft.autoLanguages.push_back(L"ru");
|
draft.autoLanguages.push_back(L"ru");
|
||||||
draft.autoNever = parseCommaList(value(w, 405));
|
draft.autoNever = parseCommaList(value(w, 405));
|
||||||
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
|
||||||
{
|
|
||||||
auto text = lower(value(w, id));
|
|
||||||
std::replace(text.begin(), text.end(), L'\n', L',');
|
|
||||||
auto words = parseCommaList(text);
|
|
||||||
std::sort(words.begin(), words.end());
|
|
||||||
words.erase(std::unique(words.begin(), words.end()), words.end());
|
|
||||||
draft.autoWords[language] = std::move(words);
|
|
||||||
}
|
|
||||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||||
draft.autoPrefixChars = value(w, 408);
|
draft.autoPrefixChars = value(w, 408);
|
||||||
draft.autoSuffixChars = value(w, 409);
|
draft.autoSuffixChars = value(w, 409);
|
||||||
@@ -1064,7 +975,6 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
validateSettings(draft);
|
validateSettings(draft);
|
||||||
view->settings->draft.autoLanguages = draft.autoLanguages;
|
view->settings->draft.autoLanguages = draft.autoLanguages;
|
||||||
view->settings->draft.autoNever = draft.autoNever;
|
view->settings->draft.autoNever = draft.autoNever;
|
||||||
view->settings->draft.autoWords = draft.autoWords;
|
|
||||||
view->settings->draft.autoExcluded = draft.autoExcluded;
|
view->settings->draft.autoExcluded = draft.autoExcluded;
|
||||||
view->settings->draft.autoMaxRank = draft.autoMaxRank;
|
view->settings->draft.autoMaxRank = draft.autoMaxRank;
|
||||||
view->settings->draft.autoMinLength = draft.autoMinLength;
|
view->settings->draft.autoMinLength = draft.autoMinLength;
|
||||||
@@ -1102,7 +1012,6 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
{
|
{
|
||||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||||
560, 680);
|
560, 680);
|
||||||
windowLayoutState(w)->autoOptions = true;
|
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||||
@@ -1112,20 +1021,13 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
for (auto &lang : v->draft.autoLanguages)
|
for (auto &lang : v->draft.autoLanguages)
|
||||||
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
||||||
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
||||||
edit(w, 404, 18, 122, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
edit(w, 404, 18, 122, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||||
value(w, 404, commaList(v->draft.autoExcluded));
|
value(w, 404, commaList(v->draft.autoExcluded));
|
||||||
label(w, L"Never correct these words (comma-separated)", 18, 184, 525);
|
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525);
|
||||||
edit(w, 405, 18, 212, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||||
value(w, 405, commaList(v->draft.autoNever));
|
value(w, 405, commaList(v->draft.autoNever));
|
||||||
label(w, L"My dictionary: correct words, separated by commas or new lines", 18, 276, 525);
|
label(w, L"Corrects after Space and a short pause.", 18, 344, 525);
|
||||||
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 525);
|
||||||
{
|
|
||||||
int x = 18 + (id - 411) * 178;
|
|
||||||
label(w, id == 411 ? L"English" : id == 412 ? L"Ukrainian" : L"Russian", x, 302, 168);
|
|
||||||
edit(w, id, x, 328, 168, 56, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
|
||||||
auto words = v->draft.autoWords.find(language);
|
|
||||||
if (words != v->draft.autoWords.end()) value(w, id, commaList(words->second));
|
|
||||||
}
|
|
||||||
label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
|
label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
|
||||||
edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
|
edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
|
||||||
value(w, 406, std::to_wstring(v->draft.autoMaxRank));
|
value(w, 406, std::to_wstring(v->draft.autoMaxRank));
|
||||||
@@ -1143,108 +1045,10 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
||||||
button(w, L"OK", IDOK, 326, 632, 100);
|
button(w, L"OK", IDOK, 326, 632, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
||||||
RECT initial{};
|
|
||||||
GetWindowRect(w, &initial);
|
|
||||||
initial.right += scale(w, 200);
|
|
||||||
initial.bottom += scale(w, 140);
|
|
||||||
fitToMonitor(w, initial);
|
|
||||||
EnableWindow(parent, FALSE);
|
EnableWindow(parent, FALSE);
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
}
|
}
|
||||||
struct OcrOptionsView { HWND parent; SettingsView *settings; };
|
|
||||||
Settings ocrDraft(HWND w, OcrOptionsView *v)
|
|
||||||
{
|
|
||||||
auto draft = v->settings->draft;
|
|
||||||
draft.useTesseract = checked(w, 501);
|
|
||||||
draft.tesseractPath = value(w, 502);
|
|
||||||
draft.tesseractLanguages = value(w, 503);
|
|
||||||
validateSettings(draft);
|
|
||||||
return draft;
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK ocrOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|
||||||
{
|
|
||||||
auto *v = reinterpret_cast<OcrOptionsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
|
||||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
|
||||||
if (msg == WM_COMMAND)
|
|
||||||
{
|
|
||||||
int id = LOWORD(wp);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (id == 507)
|
|
||||||
{
|
|
||||||
if ((INT_PTR)ShellExecuteW(w, L"open", L"https://github.com/UB-Mannheim/tesseract/wiki#tesseract-installer-for-windows",
|
|
||||||
nullptr, nullptr, SW_SHOWNORMAL) <= 32)
|
|
||||||
throw std::runtime_error("Could not open the Tesseract download page in your browser");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (id == 504)
|
|
||||||
{
|
|
||||||
wchar_t path[32768]{};
|
|
||||||
OPENFILENAMEW picker{sizeof(picker)};
|
|
||||||
picker.hwndOwner = w; picker.lpstrFile = path; picker.nMaxFile = std::size(path);
|
|
||||||
picker.lpstrFilter = L"Tesseract executable (tesseract.exe)\0tesseract.exe\0Executables\0*.exe\0";
|
|
||||||
picker.Flags = OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR;
|
|
||||||
if (GetOpenFileNameW(&picker)) value(w, 502, path);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (id == 505 || id == IDOK)
|
|
||||||
{
|
|
||||||
auto draft = ocrDraft(w, v);
|
|
||||||
if (id == 505)
|
|
||||||
{
|
|
||||||
draft.useTesseract = true;
|
|
||||||
validateTesseract(draft);
|
|
||||||
SetDlgItemTextW(w, 506, (L"Ready: " + draft.tesseractLanguages).c_str());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
validateTesseract(draft);
|
|
||||||
v->settings->draft.useTesseract = draft.useTesseract;
|
|
||||||
v->settings->draft.tesseractPath = draft.tesseractPath;
|
|
||||||
v->settings->draft.tesseractLanguages = draft.tesseractLanguages;
|
|
||||||
DestroyWindow(w);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
|
||||||
}
|
|
||||||
catch (const std::exception &e) { error(w, e); return 0; }
|
|
||||||
}
|
|
||||||
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
|
||||||
if (msg == WM_NCDESTROY)
|
|
||||||
{
|
|
||||||
EnableWindow(v->parent, TRUE);
|
|
||||||
SetForegroundWindow(v->parent);
|
|
||||||
disposeFont(w); delete v;
|
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
|
||||||
}
|
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
|
||||||
}
|
|
||||||
void showOcrOptions(HWND parent, SettingsView *v)
|
|
||||||
{
|
|
||||||
HWND w = makeWindow(*v->app, L"KSwitcher.OcrOptions", L"Image text recognition", ocrOptionsProc, 640, 450);
|
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
|
|
||||||
label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600);
|
|
||||||
checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
|
|
||||||
check(w, 501, v->draft.useTesseract);
|
|
||||||
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
|
|
||||||
edit(w, 502, 18, 118, 484);
|
|
||||||
value(w, 502, v->draft.tesseractPath);
|
|
||||||
button(w, L"Browse…", 504, 514, 117, 106);
|
|
||||||
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
|
|
||||||
edit(w, 503, 18, 188, 602);
|
|
||||||
value(w, 503, v->draft.tesseractLanguages);
|
|
||||||
button(w, L"Check installation", 505, 18, 228, 180);
|
|
||||||
control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24);
|
|
||||||
button(w, L"Download Tesseract…", 507, 18, 266, 180);
|
|
||||||
label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408);
|
|
||||||
label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602);
|
|
||||||
label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602);
|
|
||||||
button(w, L"OK", IDOK, 406, 390, 100);
|
|
||||||
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
|
|
||||||
EnableWindow(parent, FALSE);
|
|
||||||
showRequestedWindow(w); SetForegroundWindow(w);
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (msg == WM_DPICHANGED)
|
if (msg == WM_DPICHANGED)
|
||||||
@@ -1280,7 +1084,6 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
showLogs(*v->app);
|
showLogs(*v->app);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
collectLayout(w, v);
|
collectLayout(w, v);
|
||||||
@@ -1381,14 +1184,6 @@ struct SnippetView
|
|||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
int selection = -1;
|
int selection = -1;
|
||||||
};
|
};
|
||||||
void updateSnippetEditor(HWND w, SnippetView *v)
|
|
||||||
{
|
|
||||||
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
|
||||||
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
|
|
||||||
value(w, 303, selected ? v->snippets[v->selection].text : L"");
|
|
||||||
for (int id : {302, 303, 311, 312, 313})
|
|
||||||
EnableWindow(GetDlgItem(w, id), selected);
|
|
||||||
}
|
|
||||||
void fillSnippets(HWND w, SnippetView *v)
|
void fillSnippets(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
||||||
@@ -1396,7 +1191,6 @@ void fillSnippets(HWND w, SnippetView *v)
|
|||||||
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
||||||
if (v->selection >= 0)
|
if (v->selection >= 0)
|
||||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||||
updateSnippetEditor(w, v);
|
|
||||||
}
|
}
|
||||||
void collectSnippet(HWND w, SnippetView *v)
|
void collectSnippet(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
@@ -1433,7 +1227,11 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
collectSnippet(w, v);
|
collectSnippet(w, v);
|
||||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||||
updateSnippetEditor(w, v);
|
if (v->selection >= 0)
|
||||||
|
{
|
||||||
|
value(w, 302, v->snippets[v->selection].trigger);
|
||||||
|
value(w, 303, v->snippets[v->selection].text);
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 310)
|
if (id == 310)
|
||||||
@@ -1442,6 +1240,8 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->snippets.push_back({L";new", L""});
|
v->snippets.push_back({L";new", L""});
|
||||||
v->selection = (int)v->snippets.size() - 1;
|
v->selection = (int)v->snippets.size() - 1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
|
value(w, 302, L";new");
|
||||||
|
value(w, 303, L"");
|
||||||
SetFocus(GetDlgItem(w, 302));
|
SetFocus(GetDlgItem(w, 302));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1452,14 +1252,14 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||||
v->selection = -1;
|
v->selection = -1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
SetFocus(GetDlgItem(w, 301));
|
value(w, 302, L"");
|
||||||
|
value(w, 303, L"");
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 312 || id == 313)
|
if (id == 312)
|
||||||
{
|
{
|
||||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
|
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}");
|
||||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == IDOK)
|
if (id == IDOK)
|
||||||
@@ -1525,7 +1325,7 @@ void refreshHistory(App &app)
|
|||||||
search(app.historyWindow, v);
|
search(app.historyWindow, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void showHistory(App &app, bool visible)
|
void showHistory(App &app)
|
||||||
{
|
{
|
||||||
if (!app.history)
|
if (!app.history)
|
||||||
{
|
{
|
||||||
@@ -1533,7 +1333,7 @@ void showHistory(App &app, bool visible)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HWND foreground = GetForegroundWindow();
|
HWND foreground = GetForegroundWindow();
|
||||||
if (visible && app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
|
if (app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
|
||||||
{
|
{
|
||||||
auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA);
|
auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA);
|
||||||
if (v->items.size() > 1)
|
if (v->items.size() > 1)
|
||||||
@@ -1546,7 +1346,6 @@ void showHistory(App &app, bool visible)
|
|||||||
{
|
{
|
||||||
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
|
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
|
||||||
app.historyWindow = w;
|
app.historyWindow = w;
|
||||||
windowLayoutState(w)->history = true;
|
|
||||||
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
||||||
label(w, L"Search clipboard history", 16, 12, 500);
|
label(w, L"Search clipboard history", 16, 12, 500);
|
||||||
edit(w, 101, 16, 38, 728);
|
edit(w, 101, 16, 38, 728);
|
||||||
@@ -1563,7 +1362,6 @@ void showHistory(App &app, bool visible)
|
|||||||
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
||||||
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
||||||
}
|
}
|
||||||
if (!visible) return;
|
|
||||||
POINT cursor;
|
POINT cursor;
|
||||||
GetCursorPos(&cursor);
|
GetCursorPos(&cursor);
|
||||||
MONITORINFO monitor{sizeof(monitor)};
|
MONITORINFO monitor{sizeof(monitor)};
|
||||||
@@ -1577,11 +1375,251 @@ void showHistory(App &app, bool visible)
|
|||||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||||
refreshHistory(app);
|
refreshHistory(app);
|
||||||
}
|
}
|
||||||
#endif
|
namespace
|
||||||
#ifndef KS_RESIDENT_APP
|
{
|
||||||
#include "ui_log.inl"
|
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||||
#else
|
LogClear = 606;
|
||||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
|
{
|
||||||
|
// Header notifications go to the ListView, not its top-level parent.
|
||||||
|
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
|
||||||
|
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
|
||||||
|
{
|
||||||
|
auto *draw = (NMCUSTOMDRAW *)lp;
|
||||||
|
if (draw->dwDrawStage == CDDS_PREPAINT)
|
||||||
|
{
|
||||||
|
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||||
|
return CDRF_NOTIFYITEMDRAW;
|
||||||
|
}
|
||||||
|
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
|
||||||
|
{
|
||||||
|
int saved = SaveDC(draw->hdc);
|
||||||
|
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||||
|
RECT edge = draw->rc;
|
||||||
|
edge.top = edge.bottom - 1;
|
||||||
|
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
||||||
|
edge = draw->rc;
|
||||||
|
edge.left = edge.right - 1;
|
||||||
|
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
||||||
|
wchar_t title[128]{};
|
||||||
|
HDITEMW item{};
|
||||||
|
item.mask = HDI_TEXT;
|
||||||
|
item.pszText = title;
|
||||||
|
item.cchTextMax = 128;
|
||||||
|
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
|
||||||
|
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
|
||||||
|
if (headerFont)
|
||||||
|
SelectObject(draw->hdc, headerFont);
|
||||||
|
SetBkMode(draw->hdc, TRANSPARENT);
|
||||||
|
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
|
||||||
|
RECT text = draw->rc;
|
||||||
|
InflateRect(&text, -scale(w, 6), 0);
|
||||||
|
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||||
|
RestoreDC(draw->hdc, saved);
|
||||||
|
return CDRF_SKIPDEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
RemoveWindowSubclass(w, logListProc, 1);
|
||||||
|
return DefSubclassProc(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void copyLogRows(HWND w, App &app)
|
||||||
|
{
|
||||||
|
HWND list = GetDlgItem(w, LogRows);
|
||||||
|
std::wstring copied;
|
||||||
|
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
||||||
|
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
||||||
|
{
|
||||||
|
for (int column = 0; column < 4; ++column)
|
||||||
|
{
|
||||||
|
std::vector<wchar_t> text(32768);
|
||||||
|
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
|
||||||
|
if (column)
|
||||||
|
copied += L'\t';
|
||||||
|
copied += text.data();
|
||||||
|
}
|
||||||
|
copied += L"\r\n";
|
||||||
|
}
|
||||||
|
if (!copied.empty())
|
||||||
|
app.ownSequence = writeClipboard(app.window, textClip(copied));
|
||||||
|
}
|
||||||
|
void refreshLogView(HWND w, App &app)
|
||||||
|
{
|
||||||
|
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
|
||||||
|
std::wstring contents;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
contents = readDiagnosticLog(previous);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
contents = L"Could not read the log: " + wide(e.what());
|
||||||
|
}
|
||||||
|
HWND list = GetDlgItem(w, LogRows);
|
||||||
|
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||||
|
ListView_DeleteAllItems(list);
|
||||||
|
std::wistringstream lines(contents);
|
||||||
|
std::wstring line, timestamp;
|
||||||
|
int row = 0;
|
||||||
|
while (std::getline(lines, line))
|
||||||
|
{
|
||||||
|
if (!line.empty() && line.back() == L'\r')
|
||||||
|
line.pop_back();
|
||||||
|
if (line.empty())
|
||||||
|
continue;
|
||||||
|
auto firstSpace = line.find(L' ');
|
||||||
|
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
|
||||||
|
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
|
||||||
|
{
|
||||||
|
timestamp = line.substr(0, secondSpace);
|
||||||
|
line.erase(0, secondSpace + 1);
|
||||||
|
}
|
||||||
|
auto id = line.find(L" id=");
|
||||||
|
std::wstring attempt;
|
||||||
|
if (id != line.npos)
|
||||||
|
{
|
||||||
|
auto end = line.find(L' ', id + 4);
|
||||||
|
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
|
||||||
|
}
|
||||||
|
std::wstring event = line;
|
||||||
|
if (event.starts_with(L" ageMs="))
|
||||||
|
{
|
||||||
|
auto end = event.find(L' ', 8);
|
||||||
|
if (end != event.npos)
|
||||||
|
event.erase(0, end + 1);
|
||||||
|
}
|
||||||
|
auto equals = event.find(L'=');
|
||||||
|
if (equals != event.npos)
|
||||||
|
{
|
||||||
|
auto key = event.rfind(L' ', equals);
|
||||||
|
if (key != event.npos)
|
||||||
|
event.resize(key);
|
||||||
|
}
|
||||||
|
LVITEMW item{};
|
||||||
|
item.mask = LVIF_TEXT;
|
||||||
|
item.iItem = row;
|
||||||
|
item.pszText = timestamp.data();
|
||||||
|
ListView_InsertItem(list, &item);
|
||||||
|
ListView_SetItemText(list, row, 1, attempt.data());
|
||||||
|
ListView_SetItemText(list, row, 2, event.data());
|
||||||
|
ListView_SetItemText(list, row, 3, line.data());
|
||||||
|
++row;
|
||||||
|
}
|
||||||
|
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||||
|
InvalidateRect(list, nullptr, TRUE);
|
||||||
|
if (row)
|
||||||
|
ListView_EnsureVisible(list, row - 1, FALSE);
|
||||||
|
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
||||||
|
: L"Logging disabled. Showing previously saved entries.");
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (msg == WM_NCCREATE)
|
||||||
|
{
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if (!app)
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case WM_COMMAND:
|
||||||
|
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
||||||
|
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
||||||
|
{
|
||||||
|
refreshLogView(w, *app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
try { copyLogRows(w, *app); }
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||||
|
refreshLogView(w, *app);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
||||||
|
break;
|
||||||
|
[[fallthrough]];
|
||||||
|
case WM_CLOSE:
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
case WM_NOTIFY:
|
||||||
|
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
|
||||||
|
{
|
||||||
|
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
||||||
|
if (GetKeyState(VK_CONTROL) & 0x8000)
|
||||||
|
{
|
||||||
|
if (key == 'A')
|
||||||
|
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
||||||
|
if (key == 'C')
|
||||||
|
{
|
||||||
|
try { copyLogRows(w, *app); }
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_DPICHANGED:
|
||||||
|
resizeForDpi(w, wp, lp);
|
||||||
|
return 0;
|
||||||
|
case WM_NCDESTROY:
|
||||||
|
app->logWindow = nullptr;
|
||||||
|
disposeFont(w);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void showLogs(App &app)
|
||||||
|
{
|
||||||
|
if (app.logWindow)
|
||||||
|
{
|
||||||
|
showRequestedWindow(app.logWindow);
|
||||||
|
activateWindow(app.logWindow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
|
||||||
|
app.logWindow = w;
|
||||||
|
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
|
||||||
|
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||||
|
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||||
|
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||||
|
button(w, L"Refresh", LogRefresh, 330, 14, 110);
|
||||||
|
button(w, L"Copy selected", LogCopy, 454, 14, 140);
|
||||||
|
button(w, L"Clear log", LogClear, 608, 14, 110);
|
||||||
|
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
|
||||||
|
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||||
|
InitCommonControlsEx(&common);
|
||||||
|
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||||
|
LogRows, 16, 82, 868, 454);
|
||||||
|
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
||||||
|
SetWindowSubclass(list, logListProc, 1, 0);
|
||||||
|
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
||||||
|
const int widths[] = {155, 65, 195, 900};
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
{
|
||||||
|
LVCOLUMNW column{};
|
||||||
|
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
||||||
|
column.pszText = const_cast<wchar_t *>(titles[i]);
|
||||||
|
column.cx = scale(w, widths[i]);
|
||||||
|
ListView_InsertColumn(list, i, &column);
|
||||||
|
}
|
||||||
|
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
|
||||||
|
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
||||||
|
refreshLogView(w, app);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
activateWindow(w);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifndef KS_RESIDENT_APP
|
#ifndef KS_RESIDENT_APP
|
||||||
void showSettings(App &app, bool visible)
|
void showSettings(App &app, bool visible)
|
||||||
@@ -1613,7 +1651,6 @@ void showSettings(App &app, bool visible)
|
|||||||
label(w, names[i], 420, 48 + i * 30, 115);
|
label(w, names[i], 420, 48 + i * 30, 115);
|
||||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||||
}
|
}
|
||||||
checkbox(w, L"Open editor after screen capture", 209, 420, 224, 340);
|
|
||||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
||||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||||
@@ -1637,7 +1674,6 @@ void showSettings(App &app, bool visible)
|
|||||||
edit(w, 243, 18, 544, 742);
|
edit(w, 243, 18, 544, 742);
|
||||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
||||||
button(w, L"View log…", 256, 278, 575, 220);
|
button(w, L"View log…", 256, 278, 575, 220);
|
||||||
button(w, L"OCR options…", 257, 518, 575, 242);
|
|
||||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||||
@@ -1668,9 +1704,8 @@ void showSnippets(App &app)
|
|||||||
edit(w, 302, 236, 44, 488);
|
edit(w, 302, 236, 44, 488);
|
||||||
label(w, L"Replacement text", 236, 82, 480);
|
label(w, L"Replacement text", 236, 82, 480);
|
||||||
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488);
|
||||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
||||||
button(w, L"Insert Enter marker", 313, 436, 430, 190);
|
|
||||||
button(w, L"Save", IDOK, 510, 478, 100);
|
button(w, L"Save", IDOK, 510, 478, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
|||||||
-357
@@ -1,357 +0,0 @@
|
|||||||
// Included inside namespace ks by ui.cpp; shares the native control/layout helpers.
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
|
||||||
LogClear = 606, LogProgram = 607, LogCommand = 608, LogResult = 609, LogProblems = 610,
|
|
||||||
LogDetails = 611, LogReset = 612;
|
|
||||||
struct LogViewState
|
|
||||||
{
|
|
||||||
std::vector<OperationLogRow> rows;
|
|
||||||
std::vector<size_t> visible;
|
|
||||||
int sortColumn = 0;
|
|
||||||
bool descending = true, rendering = false;
|
|
||||||
};
|
|
||||||
LogViewState *logState(HWND w)
|
|
||||||
{
|
|
||||||
return reinterpret_cast<LogViewState *>(GetPropW(w, L"LogViewState"));
|
|
||||||
}
|
|
||||||
const std::wstring &logCell(const OperationLogRow &row, int column)
|
|
||||||
{
|
|
||||||
switch (column)
|
|
||||||
{
|
|
||||||
case 0:
|
|
||||||
return row.time;
|
|
||||||
case 1:
|
|
||||||
return row.process;
|
|
||||||
case 2:
|
|
||||||
return row.command;
|
|
||||||
case 3:
|
|
||||||
return row.source;
|
|
||||||
case 4:
|
|
||||||
return row.target;
|
|
||||||
default:
|
|
||||||
return row.outcome;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void showLogDetails(HWND w)
|
|
||||||
{
|
|
||||||
auto *state = logState(w);
|
|
||||||
if (!state || state->rendering)
|
|
||||||
return;
|
|
||||||
int selected = ListView_GetNextItem(GetDlgItem(w, LogRows), -1, LVNI_SELECTED);
|
|
||||||
value(w, LogDetails,
|
|
||||||
selected >= 0 && (size_t)selected < state->visible.size()
|
|
||||||
? state->rows[state->visible[selected]].details
|
|
||||||
: L"Select an operation to inspect its text and stages.");
|
|
||||||
}
|
|
||||||
void renderLogRows(HWND w, App &app)
|
|
||||||
{
|
|
||||||
auto *state = logState(w);
|
|
||||||
if (!state)
|
|
||||||
return;
|
|
||||||
state->rendering = true;
|
|
||||||
state->visible.clear();
|
|
||||||
auto matches = [&](int id, const std::wstring &field)
|
|
||||||
{ return SendDlgItemMessageW(w, id, CB_GETCURSEL, 0, 0) <= 0 || value(w, id) == field; };
|
|
||||||
for (size_t i = 0; i < state->rows.size(); ++i)
|
|
||||||
{
|
|
||||||
const auto &row = state->rows[i];
|
|
||||||
if (matches(LogProgram, row.process) && matches(LogCommand, row.command) &&
|
|
||||||
matches(LogResult, row.outcome) && (!checked(w, LogProblems) || row.problem))
|
|
||||||
state->visible.push_back(i);
|
|
||||||
}
|
|
||||||
std::stable_sort(state->visible.begin(), state->visible.end(),
|
|
||||||
[&](size_t a, size_t b)
|
|
||||||
{
|
|
||||||
const auto &left = logCell(state->rows[a], state->sortColumn);
|
|
||||||
const auto &right = logCell(state->rows[b], state->sortColumn);
|
|
||||||
if (left == right)
|
|
||||||
return state->descending ? a > b : a < b;
|
|
||||||
int comparison = CompareStringOrdinal(left.c_str(), -1, right.c_str(), -1, TRUE);
|
|
||||||
return state->descending ? comparison == CSTR_GREATER_THAN
|
|
||||||
: comparison == CSTR_LESS_THAN;
|
|
||||||
});
|
|
||||||
HWND list = GetDlgItem(w, LogRows);
|
|
||||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
|
||||||
ListView_DeleteAllItems(list);
|
|
||||||
for (size_t i = 0; i < state->visible.size(); ++i)
|
|
||||||
{
|
|
||||||
const auto &row = state->rows[state->visible[i]];
|
|
||||||
LVITEMW item{};
|
|
||||||
item.mask = LVIF_TEXT;
|
|
||||||
item.iItem = (int)i;
|
|
||||||
item.pszText = const_cast<wchar_t *>(row.time.c_str());
|
|
||||||
ListView_InsertItem(list, &item);
|
|
||||||
for (int column = 1; column < 6; ++column)
|
|
||||||
ListView_SetItemText(list, (int)i, column, const_cast<wchar_t *>(logCell(row, column).c_str()));
|
|
||||||
}
|
|
||||||
HWND header = ListView_GetHeader(list);
|
|
||||||
for (int i = 0; i < 6; ++i)
|
|
||||||
{
|
|
||||||
HDITEMW item{};
|
|
||||||
item.mask = HDI_FORMAT;
|
|
||||||
Header_GetItem(header, i, &item);
|
|
||||||
item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN);
|
|
||||||
if (i == state->sortColumn)
|
|
||||||
item.fmt |= state->descending ? HDF_SORTDOWN : HDF_SORTUP;
|
|
||||||
Header_SetItem(header, i, &item);
|
|
||||||
}
|
|
||||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
|
||||||
InvalidateRect(list, nullptr, TRUE);
|
|
||||||
state->rendering = false;
|
|
||||||
value(w, LogStatus,
|
|
||||||
std::to_wstring(state->visible.size()) + L" of " + std::to_wstring(state->rows.size()) +
|
|
||||||
L" operations / events. " +
|
|
||||||
(app.settings.writeLog ? L"Diagnostic logging enabled."
|
|
||||||
: L"Logging disabled; saved entries only."));
|
|
||||||
if (!state->visible.empty())
|
|
||||||
ListView_SetItemState(list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
|
||||||
showLogDetails(w);
|
|
||||||
if (state->visible.empty())
|
|
||||||
value(w, LogDetails,
|
|
||||||
state->rows.empty()
|
|
||||||
? L"No recorded operations. Use a conversion shortcut with logging enabled, then refresh."
|
|
||||||
: L"No operations match these filters. Reset filters to show all entries.");
|
|
||||||
}
|
|
||||||
void fillLogFilter(HWND w, int id, const std::set<std::wstring> &choices)
|
|
||||||
{
|
|
||||||
auto previous = value(w, id);
|
|
||||||
HWND combo = GetDlgItem(w, id);
|
|
||||||
SendMessageW(combo, CB_RESETCONTENT, 0, 0);
|
|
||||||
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"All");
|
|
||||||
int selected = 0, index = 1;
|
|
||||||
for (const auto &choice : choices)
|
|
||||||
{
|
|
||||||
if (choice.empty())
|
|
||||||
continue;
|
|
||||||
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)choice.c_str());
|
|
||||||
if (choice == previous)
|
|
||||||
selected = index;
|
|
||||||
++index;
|
|
||||||
}
|
|
||||||
SendMessageW(combo, CB_SETCURSEL, selected, 0);
|
|
||||||
}
|
|
||||||
void refreshLogView(HWND w, App &app)
|
|
||||||
{
|
|
||||||
auto *state = logState(w);
|
|
||||||
if (!state)
|
|
||||||
return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
auto contents = readDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
|
||||||
state->rows = parseOperationLog(contents);
|
|
||||||
if (contents.starts_with(L"No log yet.") || contents.starts_with(L"No previous log") ||
|
|
||||||
contents == L"The log is empty.")
|
|
||||||
state->rows.clear();
|
|
||||||
std::set<std::wstring> programs, commands, results;
|
|
||||||
for (const auto &row : state->rows)
|
|
||||||
{
|
|
||||||
programs.insert(row.process);
|
|
||||||
commands.insert(row.command);
|
|
||||||
results.insert(row.outcome);
|
|
||||||
}
|
|
||||||
fillLogFilter(w, LogProgram, programs);
|
|
||||||
fillLogFilter(w, LogCommand, commands);
|
|
||||||
fillLogFilter(w, LogResult, results);
|
|
||||||
renderLogRows(w, app);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
value(w, LogStatus, L"Could not read the log: " + wide(e.what()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void copyLogRows(HWND w, App &app)
|
|
||||||
{
|
|
||||||
auto *state = logState(w);
|
|
||||||
if (!state)
|
|
||||||
return;
|
|
||||||
std::wstring copied;
|
|
||||||
HWND list = GetDlgItem(w, LogRows);
|
|
||||||
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
|
||||||
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
|
||||||
{
|
|
||||||
if ((size_t)row >= state->visible.size())
|
|
||||||
continue;
|
|
||||||
const auto &entry = state->rows[state->visible[row]];
|
|
||||||
copied += entry.time + L"\t" + entry.process + L"\t" + entry.command + L"\t" + entry.source +
|
|
||||||
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
|
|
||||||
}
|
|
||||||
if (!copied.empty())
|
|
||||||
{
|
|
||||||
auto clip = textClip(copied);
|
|
||||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
|
||||||
app.ownSequence = writeClipboard(app.window, clip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|
||||||
{
|
|
||||||
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
|
||||||
if (msg == WM_NCCREATE)
|
|
||||||
{
|
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
|
||||||
SetPropW(w, L"LogViewState", new LogViewState);
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
if (!app)
|
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
|
||||||
switch (msg)
|
|
||||||
{
|
|
||||||
case WM_COMMAND:
|
|
||||||
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
|
||||||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
|
||||||
{
|
|
||||||
refreshLogView(w, *app);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if ((LOWORD(wp) >= LogProgram && LOWORD(wp) <= LogResult && HIWORD(wp) == CBN_SELCHANGE) ||
|
|
||||||
(LOWORD(wp) == LogProblems && HIWORD(wp) == BN_CLICKED))
|
|
||||||
{
|
|
||||||
renderLogRows(w, *app);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) == LogReset && HIWORD(wp) == BN_CLICKED)
|
|
||||||
{
|
|
||||||
for (int id : {LogProgram, LogCommand, LogResult})
|
|
||||||
SendDlgItemMessageW(w, id, CB_SETCURSEL, 0, 0);
|
|
||||||
CheckDlgButton(w, LogProblems, BST_UNCHECKED);
|
|
||||||
renderLogRows(w, *app);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
copyLogRows(w, *app);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
error(w, e);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
|
||||||
refreshLogView(w, *app);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
error(w, e);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
|
||||||
break;
|
|
||||||
[[fallthrough]];
|
|
||||||
case WM_CLOSE:
|
|
||||||
DestroyWindow(w);
|
|
||||||
return 0;
|
|
||||||
case WM_NOTIFY:
|
|
||||||
if (((NMHDR *)lp)->idFrom != LogRows)
|
|
||||||
break;
|
|
||||||
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
|
|
||||||
showLogDetails(w);
|
|
||||||
if (((NMHDR *)lp)->code == LVN_COLUMNCLICK)
|
|
||||||
{
|
|
||||||
auto *state = logState(w);
|
|
||||||
int column = ((NMLISTVIEW *)lp)->iSubItem;
|
|
||||||
state->descending = state->sortColumn == column ? !state->descending : false;
|
|
||||||
state->sortColumn = column;
|
|
||||||
renderLogRows(w, *app);
|
|
||||||
}
|
|
||||||
if (((NMHDR *)lp)->code == LVN_KEYDOWN)
|
|
||||||
{
|
|
||||||
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
|
||||||
if (GetKeyState(VK_CONTROL) & 0x8000)
|
|
||||||
{
|
|
||||||
if (key == 'A')
|
|
||||||
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
|
||||||
if (key == 'C')
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
copyLogRows(w, *app);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
error(w, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
case WM_DPICHANGED:
|
|
||||||
resizeForDpi(w, wp, lp);
|
|
||||||
return 0;
|
|
||||||
case WM_NCDESTROY:
|
|
||||||
delete logState(w);
|
|
||||||
RemovePropW(w, L"LogViewState");
|
|
||||||
app->logWindow = nullptr;
|
|
||||||
disposeFont(w);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
void showLogs(App &app)
|
|
||||||
{
|
|
||||||
if (app.logWindow)
|
|
||||||
{
|
|
||||||
showRequestedWindow(app.logWindow);
|
|
||||||
activateWindow(app.logWindow);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher diagnostic operations", logProc, 1100, 760);
|
|
||||||
app.logWindow = w;
|
|
||||||
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 280, 140);
|
|
||||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
|
||||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
|
||||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
|
||||||
button(w, L"Refresh", LogRefresh, 310, 14, 100);
|
|
||||||
button(w, L"Copy selected", LogCopy, 424, 14, 140);
|
|
||||||
button(w, L"Clear log", LogClear, 578, 14, 100);
|
|
||||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
|
|
||||||
label(w, L"Program", 16, 78, 170);
|
|
||||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
|
|
||||||
label(w, L"Command", 206, 78, 200);
|
|
||||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
|
|
||||||
label(w, L"Result", 426, 78, 300);
|
|
||||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
|
|
||||||
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
|
|
||||||
button(w, L"Reset filters", LogReset, 938, 98, 146);
|
|
||||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
|
||||||
InitCommonControlsEx(&common);
|
|
||||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
|
||||||
LogRows, 16, 142, 1068, 260);
|
|
||||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER |
|
|
||||||
LVS_EX_INFOTIP);
|
|
||||||
const wchar_t *titles[] = {L"Time", L"Program", L"Command",
|
|
||||||
L"Source layout", L"Target layout", L"Result"};
|
|
||||||
const int widths[] = {165, 145, 150, 130, 130, 320};
|
|
||||||
for (int i = 0; i < 6; ++i)
|
|
||||||
{
|
|
||||||
LVCOLUMNW column{};
|
|
||||||
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
|
||||||
column.pszText = const_cast<wchar_t *>(titles[i]);
|
|
||||||
column.cx = scale(w, widths[i]);
|
|
||||||
ListView_InsertColumn(list, i, &column);
|
|
||||||
}
|
|
||||||
auto details = edit(w, LogDetails, 16, 416, 1068, 276,
|
|
||||||
ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL | WS_BORDER);
|
|
||||||
auto hint = control(
|
|
||||||
w, L"STATIC",
|
|
||||||
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
|
|
||||||
16, 712, 860, 22);
|
|
||||||
button(w, L"Close", IDCANCEL, 974, 708, 110);
|
|
||||||
auto *layout = windowLayoutState(w);
|
|
||||||
layout->stretch = list;
|
|
||||||
layout->details = details;
|
|
||||||
layout->bottomLeft = hint;
|
|
||||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
|
||||||
arrangeWindow(w);
|
|
||||||
refreshLogView(w, app);
|
|
||||||
showRequestedWindow(w);
|
|
||||||
activateWindow(w);
|
|
||||||
}
|
|
||||||
+4
-5
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
|
|||||||
return out + L'"';
|
return out + L'"';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||||
{
|
{
|
||||||
UpdateResult result;
|
UpdateResult result;
|
||||||
GUID guid{};
|
GUID guid{};
|
||||||
@@ -56,7 +56,6 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool s
|
|||||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||||
if (showProgress) command += L" -ShowProgress";
|
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||||
@@ -95,10 +94,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool s
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool confirmUpdatedStartup(const std::wstring &eventName)
|
void confirmUpdatedStartup(const std::wstring &eventName)
|
||||||
{
|
{
|
||||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
|
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
||||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||||
return event.value && SetEvent(event.value);
|
if (event.value) SetEvent(event.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-7
@@ -2,17 +2,13 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.18";
|
inline constexpr wchar_t AppVersion[] = L"0.4.2";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
|
||||||
bool notifyUpdateAvailable(const std::wstring &version);
|
|
||||||
std::wstring updateToastXml(const std::wstring &version);
|
|
||||||
struct UpdateResult
|
struct UpdateResult
|
||||||
{
|
{
|
||||||
std::wstring state, version, error;
|
std::wstring state, version, error;
|
||||||
bool interactive{}, install{};
|
bool interactive{}, install{};
|
||||||
};
|
};
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
||||||
bool confirmUpdatedStartup(const std::wstring &eventName);
|
void confirmUpdatedStartup(const std::wstring &eventName);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-52
@@ -1,4 +1,4 @@
|
|||||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
|
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||||
@@ -18,7 +18,6 @@ public static class ConfigurationTestUi {
|
|||||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
||||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
||||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
||||||
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
|
|
||||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
||||||
@@ -39,7 +38,7 @@ $checks=0
|
|||||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||||
function Window($class) {
|
function Window($class) {
|
||||||
for($i=0;$i -lt 100;$i++) {
|
for($i=0;$i -lt 100;$i++) {
|
||||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
|
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
|
||||||
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||||
if($window -ne [IntPtr]::Zero){return $window}
|
if($window -ne [IntPtr]::Zero){return $window}
|
||||||
Start-Sleep -Milliseconds 50
|
Start-Sleep -Milliseconds 50
|
||||||
@@ -57,24 +56,6 @@ function Launch($file,$argument) {
|
|||||||
function Click($window,$id) {
|
function Click($window,$id) {
|
||||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
||||||
}
|
}
|
||||||
function TestLogs {
|
|
||||||
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
|
|
||||||
$config=Launch 'KSwitcher.Config.exe' '--logs'
|
|
||||||
$logs=Window 'KSwitcher.Logs'
|
|
||||||
[uint32]$logOwner=0
|
|
||||||
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
|
|
||||||
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
|
|
||||||
$second=Launch 'KSwitcher.Config.exe' '--logs'
|
|
||||||
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
|
|
||||||
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
|
|
||||||
Click $logs 603
|
|
||||||
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
|
|
||||||
Click $logs 606
|
|
||||||
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
|
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
|
|
||||||
Click $logs 2
|
|
||||||
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||||
@@ -86,13 +67,6 @@ try {
|
|||||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||||
$resident=Launch 'KSwitcher.exe' ''
|
$resident=Launch 'KSwitcher.exe' ''
|
||||||
$main=Window 'KSwitcher.Main'
|
$main=Window 'KSwitcher.Main'
|
||||||
if ($LogsOnly) {
|
|
||||||
TestLogs
|
|
||||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
|
||||||
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
|
|
||||||
"Passed $checks log process checks"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
[uint32]$ownerId=0
|
[uint32]$ownerId=0
|
||||||
@@ -128,29 +102,6 @@ try {
|
|||||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||||
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
||||||
$snippets=Window 'KSwitcher.Snippets'
|
$snippets=Window 'KSwitcher.Snippets'
|
||||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
|
|
||||||
foreach($id in @(302,303,311,312,313)) {
|
|
||||||
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
|
|
||||||
}
|
|
||||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
|
||||||
foreach($id in @(302,303,311,312,313)) {
|
|
||||||
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
|
|
||||||
}
|
|
||||||
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
|
|
||||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
|
||||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
|
|
||||||
foreach($id in @(302,303,311,312,313)) {
|
|
||||||
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
|
|
||||||
}
|
|
||||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
|
|
||||||
$snippetText=[Text.StringBuilder]::new(128)
|
|
||||||
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
|
||||||
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
|
|
||||||
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
|
|
||||||
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
|
|
||||||
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
|
|
||||||
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
|
||||||
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
|
|
||||||
$second=Launch 'KSwitcher.Config.exe' ''
|
$second=Launch 'KSwitcher.Config.exe' ''
|
||||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
@@ -159,7 +110,6 @@ try {
|
|||||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||||
Click $snippets 2
|
Click $snippets 2
|
||||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||||
TestLogs
|
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$null=Window 'KSwitcher.Settings'
|
$null=Window 'KSwitcher.Settings'
|
||||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
|||||||
+1
-233
@@ -4,32 +4,9 @@
|
|||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
|
|
||||||
{
|
|
||||||
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
|
|
||||||
return DefSubclassProc(w, message, wp, lp);
|
|
||||||
}
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
struct InputRegressionAccess {
|
struct InputRegressionAccess {
|
||||||
static inline bool delivery = true;
|
|
||||||
static inline int notifications = 0;
|
|
||||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
|
||||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
|
||||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||||
static bool updateTimer(App &a) {
|
|
||||||
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
|
||||||
App::windowProc(a.window,WM_TIMER,5,0);
|
|
||||||
bool first=KillTimer(a.window,5)!=FALSE;
|
|
||||||
App::windowProc(a.window,WM_TIMER,5,0);
|
|
||||||
bool repeated=KillTimer(a.window,5)!=FALSE;
|
|
||||||
a.updateRunning=false;
|
|
||||||
return first && repeated && !a.updateWorker.joinable();
|
|
||||||
}
|
|
||||||
static std::wstring updateResult(App &a,const wchar_t *state,const wchar_t *version) {
|
|
||||||
auto result=new UpdateResult; result->state=state; result->version=version;
|
|
||||||
App::windowProc(a.window,UpdateMessage,0,(LPARAM)result);
|
|
||||||
return a.notifiedUpdate;
|
|
||||||
}
|
|
||||||
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -51,34 +28,9 @@ int main(int argc, char **argv) {
|
|||||||
{
|
{
|
||||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||||
ks::InputRegressionAccess::setup(app);
|
ks::InputRegressionAccess::setup(app);
|
||||||
ks::InputRegressionAccess::notificationSetup(app);
|
|
||||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||||
require(app.window!=nullptr,"message-only test window");
|
require(app.window!=nullptr,"message-only test window");
|
||||||
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
|
||||||
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
|
||||||
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
|
||||||
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
|
|
||||||
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
|
|
||||||
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
|
|
||||||
"successful startup signals the updater before reporting completion");
|
|
||||||
CloseHandle(started);
|
|
||||||
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
|
||||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
|
|
||||||
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
|
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
|
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
|
|
||||||
ks::InputRegressionAccess::delivery=false;
|
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
|
|
||||||
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
|
|
||||||
ks::InputRegressionAccess::delivery=true;
|
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
|
|
||||||
auto toast=ks::updateToastXml(L"1&2<3\"");
|
|
||||||
require(toast.find(L"1&2<3"")!=toast.npos,"update version is XML escaped");
|
|
||||||
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
|
|
||||||
"toast body and action both review the update instead of installing automatically");
|
|
||||||
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
||||||
for(int size:{22,44,55}) {
|
for(int size:{22,44,55}) {
|
||||||
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
||||||
@@ -207,67 +159,6 @@ int main(int argc, char **argv) {
|
|||||||
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
||||||
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
||||||
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
||||||
for(auto shape : {ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
|
|
||||||
std::vector<ks::ScreenshotMark> objects={{{100,50},{160,110},0},{{10,80},{100,80},0},{{10,140},{55,80},0}};
|
|
||||||
objects[0].shape=shape;
|
|
||||||
ks::linkScreenshotEndpoint(objects[1],false,{100,80},objects,6);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].toLink.target==objects[0].id && objects[1].to.x==100,"arrow attaches to shape boundary");
|
|
||||||
ks::linkScreenshotEndpoint(objects[2],false,{55,80},objects,6);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[2].toLink.target==objects[1].id,"arrow attaches to another arrow body");
|
|
||||||
auto targetId=objects[0].id,arrowId=objects[1].id;
|
|
||||||
auto snapshot=objects; ks::ScreenshotHistory linksHistory; linksHistory.remember(objects);
|
|
||||||
// Reverse storage order so dependency resolution cannot rely on insertion order.
|
|
||||||
std::reverse(objects.begin(),objects.end());
|
|
||||||
objects[2]=ks::dragScreenshotMark(objects[2],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].to.x==120 && objects[1].to.y==100 && objects[0].to.x==65 && objects[0].to.y==90,
|
|
||||||
"shape-arrow-arrow chain updates in dependency order");
|
|
||||||
require(linksHistory.undo(objects) && objects[1].to.x==100 && objects[2].toLink.target==arrowId,
|
|
||||||
"undo restores geometry and generic links");
|
|
||||||
require(linksHistory.redo(objects) && objects[0].to.x==65,"redo restores moved chain");
|
|
||||||
auto resized=objects;
|
|
||||||
auto corner=resized[2].to;
|
|
||||||
resized[2]=ks::dragScreenshotMark(resized[2],ks::MarkPart::End,corner,{corner.x+20,corner.y+20},400,300);
|
|
||||||
ks::resolveScreenshotLinks(resized);
|
|
||||||
require(resized[1].to.x==120 && resized[1].to.y==110 && resized[0].to.y==95,
|
|
||||||
"resizing shapes updates both levels of attached arrows");
|
|
||||||
ks::linkScreenshotEndpoint(objects[1],true,objects[0].from,objects,6);
|
|
||||||
require(!objects[1].fromLink.target,"cyclic arrow links are rejected");
|
|
||||||
auto clone=ks::cloneScreenshotMark(objects[1],objects,400,300);
|
|
||||||
require(!clone.id && !clone.fromLink.target && !clone.toLink.target,"cloned connector has independent identity and no links");
|
|
||||||
auto endpoint=objects[1].to;
|
|
||||||
objects.erase(objects.begin()+2); ks::resolveScreenshotLinks(objects);
|
|
||||||
require(!objects[1].toLink.target && objects[1].to.x==endpoint.x && objects[0].toLink.target==arrowId,
|
|
||||||
"deleting shape detaches only its dependents without moving endpoints");
|
|
||||||
objects.push_back(snapshot[0]); objects.back().id=0; ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects.back().id!=targetId && !objects[1].toLink.target,"new objects cannot revive deleted links");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
std::vector<ks::ScreenshotMark> objects={{{80,40},{80,40},0},{{10,40},{80,40},0}};
|
|
||||||
objects[0].text=L"Target"; objects[0].fontSize=20;
|
|
||||||
auto bounds=ks::screenshotTextBounds(objects[0]);
|
|
||||||
ks::linkScreenshotEndpoint(objects[1],false,{bounds.left,(bounds.top+bounds.bottom)/2},objects,6);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].toLink.target==objects[0].id,"arrow attaches to text block");
|
|
||||||
auto old=objects[1].to;
|
|
||||||
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{30,20},400,300);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].to.x==old.x+30 && objects[1].to.y==old.y+20,"text attachment follows movement");
|
|
||||||
objects[0].rotation=90; ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].to.x!=old.x+30 || objects[1].to.y!=old.y+20,"text attachment follows rotation");
|
|
||||||
}
|
|
||||||
for(bool freehand : {false,true}) {
|
|
||||||
std::vector<ks::ScreenshotMark> objects={{{80,60},{160,80},0},{{10,140},{110,60},0}};
|
|
||||||
objects[0].points={{80,60},{140,60},{160,80}}; objects[0].freehand=freehand;
|
|
||||||
ks::linkScreenshotEndpoint(objects[1],false,{110,60},objects,6);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].toLink.target==objects[0].id,"arrow attaches to freehand and highlight strokes");
|
|
||||||
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
|
||||||
ks::resolveScreenshotLinks(objects);
|
|
||||||
require(objects[1].to.x==130 && objects[1].to.y==80,"stroke attachment follows the original segment");
|
|
||||||
}
|
|
||||||
auto lastPoint=linked[2].from;
|
auto lastPoint=linked[2].from;
|
||||||
linked.erase(linked.begin());
|
linked.erase(linked.begin());
|
||||||
ks::resolveScreenshotLinks(linked);
|
ks::resolveScreenshotLinks(linked);
|
||||||
@@ -594,113 +485,9 @@ int main(int argc, char **argv) {
|
|||||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||||
ks::showSettings(app,false);
|
ks::showSettings(app,false);
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
|
||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
|
||||||
require(autoOptions!=nullptr,"auto-correction options open");
|
|
||||||
ShowWindow(autoOptions,SW_HIDE);
|
|
||||||
{
|
|
||||||
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
|
|
||||||
"auto-correction dialog supports resizing");
|
|
||||||
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
|
|
||||||
GetWindowRect(autoOptions,&original);
|
|
||||||
// Use a client size above the scrolling baseline at every DPI.
|
|
||||||
UINT dpi=GetDpiForWindow(autoOptions);
|
|
||||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
|
||||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
|
||||||
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
|
||||||
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
|
||||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
|
||||||
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
|
||||||
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
|
||||||
for(int i=0;i<3;++i) {
|
|
||||||
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
|
||||||
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
|
||||||
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
|
||||||
"dictionary columns share extra width and receive extra height");
|
|
||||||
}
|
|
||||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
|
||||||
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
|
|
||||||
"auto-correction buttons follow bottom-right corner");
|
|
||||||
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
|
||||||
after[2].bottom<buttonAfter.top,
|
|
||||||
"resized dictionary fields and footer do not overlap");
|
|
||||||
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
|
|
||||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
|
||||||
}
|
|
||||||
for(int id:{411,412,413}) {
|
|
||||||
RECT control{},client{};
|
|
||||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
|
||||||
MapWindowPoints(nullptr,autoOptions,reinterpret_cast<POINT*>(&control),2);
|
|
||||||
GetClientRect(autoOptions,&client);
|
|
||||||
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
|
||||||
"personal dictionary field fits options window");
|
|
||||||
}
|
|
||||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
|
||||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
|
||||||
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
|
||||||
ShowWindow(autoOptions,SW_HIDE);
|
|
||||||
wchar_t personalWords[128]{};
|
|
||||||
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
|
||||||
require(std::wstring(personalWords)==L"релізик, словечко",
|
|
||||||
"personal dictionary normalizes case, newlines and duplicate words");
|
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
|
||||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
|
||||||
ks::showHistory(app,false);
|
|
||||||
auto history=app.historyWindow;
|
|
||||||
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
|
|
||||||
{
|
|
||||||
app.history=std::make_unique<ks::History>(temp/L"history-search");
|
|
||||||
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
|
|
||||||
app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings);
|
|
||||||
// This App has no general worker: a search on that queue can never finish.
|
|
||||||
auto originalFocus=GetFocus();
|
|
||||||
auto pumpUntil=[&](int count) {
|
|
||||||
auto deadline=GetTickCount64()+3000;
|
|
||||||
do {
|
|
||||||
MSG message{};
|
|
||||||
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
|
||||||
}
|
|
||||||
if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true;
|
|
||||||
Sleep(5);
|
|
||||||
} while(GetTickCount64()<deadline);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
ks::refreshHistory(app);
|
|
||||||
require(pumpUntil(2),"history loads independently of general worker queue");
|
|
||||||
SetDlgItemTextW(history,101,L"згенеруй");
|
|
||||||
require(pumpUntil(1),"typing filters history via debounce without focus change");
|
|
||||||
SetDlgItemTextW(history,101,L"missing query");
|
|
||||||
require(pumpUntil(0),"unmatched search clears history list");
|
|
||||||
SetDlgItemTextW(history,101,L"");
|
|
||||||
require(pumpUntil(2),"clearing search restores history");
|
|
||||||
require(GetFocus()==originalFocus,"history search preserves focus");
|
|
||||||
}
|
|
||||||
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
|
||||||
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
|
||||||
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
|
|
||||||
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
|
||||||
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
|
|
||||||
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
|
|
||||||
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
|
|
||||||
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
|
|
||||||
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
|
||||||
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
|
|
||||||
// Reproduce a row height left over from another DPI without changing desktop settings.
|
|
||||||
UINT historyDpi=GetDpiForWindow(history);
|
|
||||||
for (int staleDpi : {96, 144, 192, 240}) {
|
|
||||||
SendDlgItemMessageW(history,102,LB_SETITEMHEIGHT,0,MulDiv(88,staleDpi,96));
|
|
||||||
SendMessageW(history,WM_DPICHANGED,MAKELONG(historyDpi,historyDpi),(LPARAM)&historyFrame);
|
|
||||||
require(SendDlgItemMessageW(history,102,LB_GETITEMHEIGHT,0,0)==MulDiv(88,historyDpi,96),
|
|
||||||
"DPI change updates owner-drawn history row height through layout subclass");
|
|
||||||
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"DPI refresh preserves history list geometry");
|
|
||||||
}
|
|
||||||
auto settings=app.settingsWindow;
|
auto settings=app.settingsWindow;
|
||||||
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
|
|
||||||
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
||||||
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
for(auto pair:{std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
||||||
RECT first{},second{},overlap{};
|
RECT first{},second{},overlap{};
|
||||||
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
||||||
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
||||||
@@ -720,27 +507,8 @@ int main(int argc, char **argv) {
|
|||||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
|
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
|
||||||
GetScrollInfo(settings,SB_HORZ,&scroll);
|
GetScrollInfo(settings,SB_HORZ,&scroll);
|
||||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
|
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
|
||||||
int controlMoves = 0;
|
|
||||||
auto trackedControl = GetDlgItem(settings,201);
|
|
||||||
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
|
|
||||||
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
|
|
||||||
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
|
|
||||||
controlMoves=0;
|
|
||||||
ValidateRect(settings,nullptr);
|
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
|
||||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
|
||||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
|
||||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
|
||||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||||
controlMoves=0;
|
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
|
||||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
|
||||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
|
||||||
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
|
||||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
|
||||||
GetClientRect(settings,&client);
|
GetClientRect(settings,&client);
|
||||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||||
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
||||||
|
|||||||
+6
-303
@@ -1,43 +1,16 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include "diagnostics.h"
|
|
||||||
#include <algorithm>
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
|
|
||||||
namespace ks
|
|
||||||
{
|
|
||||||
struct InputRegressionAccess
|
|
||||||
{
|
|
||||||
static bool deferring(App &app)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(app.stateMutex);
|
|
||||||
return app.typingTarget != nullptr;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
ks::App *app{};
|
ks::App *app{};
|
||||||
HWND host{}, field{};
|
HWND host{}, field{};
|
||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
bool captured = false, failed = false;
|
bool captured = false, failed = false;
|
||||||
bool conversionOnly = false;
|
|
||||||
int pasteBehavior = 0;
|
|
||||||
int enterPresses = 0;
|
|
||||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
|
||||||
{
|
|
||||||
if (message == WM_PASTE && pasteBehavior)
|
|
||||||
{
|
|
||||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
|
||||||
return DefSubclassProc(w, message, wp, lp);
|
|
||||||
}
|
|
||||||
int stage = 0, ru = -1, en = -1;
|
int stage = 0, ru = -1, en = -1;
|
||||||
size_t punctuationCase = 0;
|
size_t punctuationCase = 0;
|
||||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
|
||||||
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
||||||
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
|
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
|
||||||
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
||||||
@@ -52,14 +25,7 @@ size_t diagnosticStart{};
|
|||||||
void require(bool ok, const char *name)
|
void require(bool ok, const char *name)
|
||||||
{
|
{
|
||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
|
||||||
wchar_t observed[2048]{};
|
|
||||||
GetWindowTextW(field, observed, 2048);
|
|
||||||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
|
||||||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
|
||||||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
|
||||||
throw std::runtime_error(name);
|
throw std::runtime_error(name);
|
||||||
}
|
|
||||||
std::cout << "PASS " << name << '\n';
|
std::cout << "PASS " << name << '\n';
|
||||||
}
|
}
|
||||||
std::wstring text()
|
std::wstring text()
|
||||||
@@ -114,26 +80,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (GetTickCount64() > deadline)
|
if (GetTickCount64() > deadline)
|
||||||
throw std::runtime_error("Native integration timed out");
|
throw std::runtime_error("Native integration timed out");
|
||||||
if (app->busy)
|
if (app->busy)
|
||||||
{
|
|
||||||
if (typeDuringCorrection && ks::InputRegressionAccess::deferring(*app))
|
|
||||||
{
|
|
||||||
typeDuringCorrection = false;
|
|
||||||
typedDuringCorrection = true;
|
|
||||||
std::vector<INPUT> inputs;
|
|
||||||
for (auto [key, up] : std::vector<std::pair<WORD, bool>>{
|
|
||||||
{'F', false}, {'F', true}, {VK_LSHIFT, false},
|
|
||||||
{'F', false}, {'F', true}, {VK_LSHIFT, true},
|
|
||||||
{'B', false}, {'B', true}, {VK_BACK, false}, {VK_BACK, true}})
|
|
||||||
{
|
|
||||||
auto input = ks::keyboardInput(key, up ? KEYEVENTF_KEYUP : 0);
|
|
||||||
input.ki.dwExtraInfo = 123; // External input must traverse the actual hook.
|
|
||||||
inputs.push_back(input);
|
|
||||||
}
|
|
||||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
|
||||||
"concurrent typing submitted during selection/paste transaction");
|
|
||||||
}
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
|
||||||
switch (stage++)
|
switch (stage++)
|
||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
@@ -186,20 +133,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
"busy conversion attempt is logged");
|
"busy conversion attempt is logged");
|
||||||
require(diagnostics.find("repeat=3") != std::string::npos,
|
require(diagnostics.find("repeat=3") != std::string::npos,
|
||||||
"three repeated conversion attempts are logged");
|
"three repeated conversion attempts are logged");
|
||||||
require(diagnostics.find("\"outcome\":\"recent-verification-mismatch\"") != std::string::npos,
|
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
|
||||||
"failed recent-word verification has explicit outcome");
|
"failed recent-word verification has explicit outcome");
|
||||||
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
|
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
|
||||||
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
|
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
|
||||||
"conversion diagnostics contain actual text and physical input for reproduction");
|
"conversion diagnostics contain actual text and physical input for reproduction");
|
||||||
}
|
}
|
||||||
if (conversionOnly)
|
|
||||||
{
|
|
||||||
pasteBehavior = 1;
|
|
||||||
prepare(L"ghbdtn", true);
|
|
||||||
app->transform(ks::Direct + ru);
|
|
||||||
stage = 40;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
||||||
prepare(L"prefix ;addr", false);
|
prepare(L"prefix ;addr", false);
|
||||||
record(L";addr");
|
record(L";addr");
|
||||||
@@ -303,185 +242,8 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 17:
|
case 17:
|
||||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||||
stage = 20;
|
|
||||||
break;
|
|
||||||
case 40:
|
|
||||||
{
|
|
||||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
|
||||||
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
|
||||||
"blocked paste records original text remaining in the actual editor");
|
|
||||||
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
|
||||||
pasteBehavior = 2;
|
|
||||||
prepare(L"ghbdtn", true);
|
|
||||||
app->transform(ks::Direct + ru);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 41:
|
|
||||||
{
|
|
||||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
|
||||||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
|
||||||
"unexpected editor content records readback mismatch");
|
|
||||||
pasteBehavior = 0;
|
|
||||||
prepare(L"привет", true);
|
|
||||||
app->transform(ks::Direct + ru);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 42:
|
|
||||||
{
|
|
||||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
|
||||||
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
|
|
||||||
"manual unchanged conversion is recorded as a problem");
|
|
||||||
diagnosticStart = diagnosticLog().size();
|
|
||||||
app->settings.writeLog = false;
|
|
||||||
ks::setLoggingEnabled(false);
|
|
||||||
prepare(L"ghbdtn", true);
|
|
||||||
app->transform(ks::Direct + ru);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 43:
|
|
||||||
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
|
||||||
"disabled diagnostics preserves conversion and writes no records");
|
|
||||||
ks::setLoggingEnabled(true);
|
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
case 30:
|
|
||||||
case 32:
|
|
||||||
case 34:
|
|
||||||
if (!captured)
|
|
||||||
{
|
|
||||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
|
||||||
captured = true;
|
|
||||||
}
|
|
||||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
|
||||||
app->settings.autoCorrect = stage == 35;
|
|
||||||
app->settings.autoLanguages = {L"en", L"ru"};
|
|
||||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
|
||||||
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
|
||||||
enterPresses = 0;
|
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
|
||||||
prepare(L"prefix ", false);
|
|
||||||
{
|
|
||||||
std::vector<INPUT> inputs;
|
|
||||||
const std::vector<WORD> keys = stage == 35
|
|
||||||
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
|
|
||||||
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
|
|
||||||
for (WORD key : keys)
|
|
||||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
|
||||||
{
|
|
||||||
auto input = ks::keyboardInput(key, flags);
|
|
||||||
input.ki.dwExtraInfo = 123;
|
|
||||||
inputs.push_back(input);
|
|
||||||
}
|
|
||||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
|
||||||
"snippet trigger and following text submitted without a pause");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 31:
|
|
||||||
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
|
|
||||||
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
|
|
||||||
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
|
|
||||||
stage = 32;
|
|
||||||
break;
|
|
||||||
case 33:
|
|
||||||
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
|
||||||
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
|
||||||
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
|
||||||
stage = 34;
|
|
||||||
break;
|
|
||||||
case 35:
|
|
||||||
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
|
|
||||||
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
|
|
||||||
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
|
|
||||||
{
|
|
||||||
std::vector<INPUT> inputs;
|
|
||||||
for (WORD key : std::wstring(L"GHBDTN "))
|
|
||||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
|
||||||
{
|
|
||||||
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
|
||||||
input.ki.dwExtraInfo = 123;
|
|
||||||
inputs.push_back(input);
|
|
||||||
}
|
|
||||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
|
||||||
"normal typing submitted after snippet completes");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 36:
|
|
||||||
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
|
||||||
finish();
|
|
||||||
break;
|
|
||||||
case 20:
|
|
||||||
if (!captured)
|
|
||||||
{
|
|
||||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
|
||||||
captured = true;
|
|
||||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
|
||||||
}
|
|
||||||
app->settings.autoCorrect = true;
|
|
||||||
app->settings.switchLayout = true;
|
|
||||||
app->settings.autoLanguages = {L"en", L"ru"};
|
|
||||||
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
|
||||||
app->autoCorrector = ks::AutoCorrector{};
|
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
|
||||||
prepare(L"ghbdtn ", false);
|
|
||||||
record(L"ghbdtn ");
|
|
||||||
typeDuringCorrection = true;
|
|
||||||
app->transform(ks::AutoCorrect);
|
|
||||||
stage = 18;
|
|
||||||
break;
|
|
||||||
case 18:
|
|
||||||
require(typedDuringCorrection && text() == L"привет аА",
|
|
||||||
"typing during correction replays in order with shift and backspace in new layout");
|
|
||||||
require(ks::readClipboard(app->window).text() == sentinel,
|
|
||||||
"concurrent typing preserves clipboard restoration");
|
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
|
||||||
prepare(L"OLDghbdtn ", false);
|
|
||||||
record(L"ghbdtn ");
|
|
||||||
typedDuringCorrection = false;
|
|
||||||
typeDuringCorrection = true;
|
|
||||||
app->transform(ks::AutoCorrect);
|
|
||||||
break;
|
|
||||||
case 19:
|
|
||||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
|
||||||
"rejected correction collapses selection before replaying typing in original layout");
|
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
|
||||||
prepare(L"", false);
|
|
||||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
|
||||||
{
|
|
||||||
std::vector<INPUT> inputs;
|
|
||||||
for (WORD key : std::wstring(L"GHBDTN F"))
|
|
||||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
|
||||||
{
|
|
||||||
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
|
||||||
input.ki.dwExtraInfo = 123;
|
|
||||||
inputs.push_back(input);
|
|
||||||
}
|
|
||||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
|
||||||
"continuous typing submitted in a single batch without a pause after Space");
|
|
||||||
}
|
|
||||||
stage = 21;
|
|
||||||
break;
|
|
||||||
case 21:
|
|
||||||
{
|
|
||||||
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
|
||||||
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
|
|
||||||
return ks::layoutLanguage(layout) == L"uk";
|
|
||||||
});
|
|
||||||
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
|
|
||||||
app->settings.autoLanguages = {L"en", L"uk"};
|
|
||||||
app->settings.cycle = {app->layouts[en].id, uk->id};
|
|
||||||
app->settings.autoWords[L"uk"] = {L"робить"};
|
|
||||||
app->autoCorrector = ks::AutoCorrector{};
|
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
|
||||||
prepare(L"hj,bnm ", false);
|
|
||||||
record(L"hj,bnm ");
|
|
||||||
app->transform(ks::AutoCorrect);
|
|
||||||
stage = 22;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 22:
|
|
||||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
|
||||||
stage = 30;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -503,7 +265,6 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
} // namespace
|
} // namespace
|
||||||
int main(int argc, char **argv)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
|
||||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
auto root = std::filesystem::temp_directory_path() /
|
auto root = std::filesystem::temp_directory_path() /
|
||||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
@@ -519,22 +280,9 @@ int main(int argc, char **argv)
|
|||||||
ks::showLogs(instance);
|
ks::showLogs(instance);
|
||||||
HWND viewer = instance.logWindow;
|
HWND viewer = instance.logWindow;
|
||||||
HWND rows = GetDlgItem(viewer, 602);
|
HWND rows = GetDlgItem(viewer, 602);
|
||||||
if (argc > 2 && std::string(argv[2]) == "--preview")
|
|
||||||
{
|
|
||||||
MSG message{};
|
|
||||||
while (IsWindow(viewer) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
|
||||||
{
|
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
|
||||||
}
|
|
||||||
CoUninitialize();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
||||||
"log viewer uses report ListView");
|
"log viewer uses report ListView");
|
||||||
require(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable columns");
|
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
|
||||||
require(GetDlgItem(viewer, 607) && GetDlgItem(viewer, 608) && GetDlgItem(viewer, 609) &&
|
|
||||||
GetDlgItem(viewer, 610) && GetDlgItem(viewer, 611),
|
|
||||||
"log viewer exposes program, command, result, problems filter and detail panel");
|
|
||||||
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
|
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
|
||||||
SetFocus(rows);
|
SetFocus(rows);
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
||||||
@@ -544,11 +292,10 @@ int main(int argc, char **argv)
|
|||||||
ks::showLogs(instance);
|
ks::showLogs(instance);
|
||||||
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
|
||||||
require(IsWindow(viewer), "refresh keeps log viewer open");
|
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
|
||||||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
||||||
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
|
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
|
||||||
"previous log or explicit empty state is displayed");
|
|
||||||
if (argc > 2 && std::string(argv[2]) == "--clear-test")
|
if (argc > 2 && std::string(argv[2]) == "--clear-test")
|
||||||
{
|
{
|
||||||
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
||||||
@@ -573,46 +320,7 @@ int main(int argc, char **argv)
|
|||||||
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
||||||
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
||||||
"logging resumes after clear without stale context");
|
"logging resumes after clear without stale context");
|
||||||
ks::DiagnosticJson operation{{"attempt", "1"}, {"process", "alpha.exe"},
|
|
||||||
{"command", "Convert / cycle"}, {"source", "English"}, {"target", "Ukrainian"},
|
|
||||||
{"outcome", "Paste verified"}, {"problem", false}, {"original", "ghbdsn"},
|
|
||||||
{"expected", ks::utf8(L"привіт")}, {"actual", ks::utf8(L"привіт")}};
|
|
||||||
ks::clearDiagnosticLog(false);
|
|
||||||
const auto successfulOperation = operation;
|
|
||||||
for (int attempt = 0; attempt < 1000; ++attempt)
|
|
||||||
ks::diagnosticOperation(operation.dump());
|
|
||||||
require(!std::filesystem::exists(logRoot / L"kswitcher.log"),
|
|
||||||
"one thousand successful operations create no log file");
|
|
||||||
operation["attempt"] = "2"; operation["process"] = "beta.exe";
|
|
||||||
operation["outcome"] = "Original text remained"; operation["problem"] = true;
|
|
||||||
operation["actual"] = operation["original"];
|
|
||||||
ks::diagnosticOperation(operation.dump());
|
|
||||||
auto problems = ks::parseOperationLog(ks::readDiagnosticLog(false));
|
|
||||||
require(problems.size() == 1 && problems[0].process == L"beta.exe" &&
|
|
||||||
problems[0].details.find(L"Completed operation") != std::wstring::npos &&
|
|
||||||
problems[0].details.find(L"Paste verified") != std::wstring::npos,
|
|
||||||
"failure writes one row with bounded preceding successful context");
|
|
||||||
// Pre-existing successful records remain viewable and filterable.
|
|
||||||
ks::log(L"Operation " + ks::wide(successfulOperation.dump()));
|
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
|
||||||
require(ListView_GetItemCount(rows) == 2, "one visible row per recorded operation");
|
|
||||||
CheckDlgButton(viewer, 610, BST_CHECKED);
|
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(610, BN_CLICKED), 0);
|
|
||||||
require(ListView_GetItemCount(rows) == 1, "only problems hides verified operations");
|
|
||||||
SendDlgItemMessageW(viewer, 607, CB_SETCURSEL, 1, 0);
|
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(607, CBN_SELCHANGE), 0);
|
|
||||||
require(ListView_GetItemCount(rows) == 0, "program filter combines with problems filter");
|
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(612, BN_CLICKED), 0);
|
|
||||||
require(ListView_GetItemCount(rows) == 2, "reset restores all operation rows");
|
|
||||||
NMLISTVIEW sort{}; sort.hdr = {rows, 602, LVN_COLUMNCLICK}; sort.iSubItem = 1;
|
|
||||||
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&sort);
|
|
||||||
wchar_t firstProgram[64]{};
|
|
||||||
ListView_GetItemText(rows, 0, 1, firstProgram, 64);
|
|
||||||
require(std::wstring(firstProgram) == L"alpha.exe", "clicking program column sorts rows");
|
|
||||||
require(GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0, "selected operation populates detail panel");
|
|
||||||
}
|
}
|
||||||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
|
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
|
||||||
auto savedClipboard = ks::readClipboard(viewer);
|
auto savedClipboard = ks::readClipboard(viewer);
|
||||||
instance.window = viewer;
|
instance.window = viewer;
|
||||||
try
|
try
|
||||||
@@ -658,11 +366,6 @@ int main(int argc, char **argv)
|
|||||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
deadline = GetTickCount64() + 30000;
|
||||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
|
||||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
|
||||||
stage = 20;
|
|
||||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
|
||||||
stage = 30;
|
|
||||||
SetTimer(host, 1, 100, nullptr);
|
SetTimer(host, 1, 100, nullptr);
|
||||||
app->execute();
|
app->execute();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-46
@@ -1,14 +1,7 @@
|
|||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "app.h"
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
namespace ks {
|
int main()
|
||||||
struct InputRegressionAccess {
|
|
||||||
static void scheduleOcr(App &app) { app.scheduleHistoryOcr(app.settings); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
int main(int argc, char **argv)
|
|
||||||
{
|
{
|
||||||
const bool tesseract = argc == 2 && std::string(argv[1]) == "--tesseract";
|
|
||||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -31,8 +24,7 @@ int main(int argc, char **argv)
|
|||||||
auto oldFont = SelectObject(dc, font);
|
auto oldFont = SelectObject(dc, font);
|
||||||
SetTextColor(dc, RGB(0, 0, 0));
|
SetTextColor(dc, RGB(0, 0, 0));
|
||||||
SetBkColor(dc, RGB(255, 255, 255));
|
SetBkColor(dc, RGB(255, 255, 255));
|
||||||
std::wstring fixture = tesseract ? L"Привіт Україно HELLO" : L"HELLO KSWITCHER";
|
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15);
|
||||||
TextOutW(dc, 20, 40, fixture.c_str(), (int)fixture.size());
|
|
||||||
GdiFlush();
|
GdiFlush();
|
||||||
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
|
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
|
||||||
SelectObject(dc, oldFont);
|
SelectObject(dc, oldFont);
|
||||||
@@ -44,45 +36,11 @@ int main(int argc, char **argv)
|
|||||||
auto decoded = ks::decodeImage(clip);
|
auto decoded = ks::decodeImage(clip);
|
||||||
if (decoded.width != 800 || decoded.height != 150)
|
if (decoded.width != 800 || decoded.height != 150)
|
||||||
throw std::runtime_error("WIC decode size mismatch");
|
throw std::runtime_error("WIC decode size mismatch");
|
||||||
ks::Settings config;
|
auto text = ks::recognizeText(clip);
|
||||||
config.useTesseract = tesseract;
|
|
||||||
auto text = ks::recognizeText(clip, config);
|
|
||||||
std::cout << "OCR: " << ks::utf8(text) << '\n';
|
std::cout << "OCR: " << ks::utf8(text) << '\n';
|
||||||
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
|
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
|
||||||
throw std::runtime_error("OCR fixture was not recognized");
|
throw std::runtime_error("OCR fixture was not recognized");
|
||||||
if (tesseract && ks::lower(text).find(L"привіт") == std::wstring::npos)
|
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n";
|
||||||
throw std::runtime_error("Ukrainian OCR fixture was not recognized");
|
|
||||||
auto temp = std::filesystem::temp_directory_path() / (L"KSwitcher-auto-ocr-" + std::to_wstring(GetCurrentProcessId()));
|
|
||||||
auto sequence = GetClipboardSequenceNumber();
|
|
||||||
{
|
|
||||||
ks::App app(GetModuleHandleW(nullptr), true, temp);
|
|
||||||
app.settings.history = true;
|
|
||||||
app.history->add(clip, L"OCR fixture", app.settings);
|
|
||||||
auto id = app.history->entries().front().id;
|
|
||||||
if (tesseract)
|
|
||||||
{
|
|
||||||
app.history->saveOcr(id, L"old Windows result", app.settings);
|
|
||||||
app.settings.useTesseract = true;
|
|
||||||
}
|
|
||||||
ks::InputRegressionAccess::scheduleOcr(app);
|
|
||||||
auto deadline = GetTickCount64() + 35000;
|
|
||||||
while ((app.history->entries().front().ocrState == 1 ||
|
|
||||||
app.history->entries().front().ocrProfile != ks::ocrProfile(app.settings)) &&
|
|
||||||
GetTickCount64() < deadline) Sleep(20);
|
|
||||||
if (app.history->search(L"HELLO").size() != 1)
|
|
||||||
throw std::runtime_error("Background OCR did not make the image searchable");
|
|
||||||
ks::History restored(temp / L"history");
|
|
||||||
if (!restored.ocrText(id) || restored.search(L"HELLO").size() != 1)
|
|
||||||
throw std::runtime_error("Background OCR did not persist its result");
|
|
||||||
if (tesseract && (restored.search(L"привіт").size() != 1 || !restored.search(L"old Windows result").empty()))
|
|
||||||
throw std::runtime_error("Engine change did not replace cached Windows OCR with Ukrainian text");
|
|
||||||
if (GetClipboardSequenceNumber() != sequence)
|
|
||||||
throw std::runtime_error("Background OCR changed the clipboard");
|
|
||||||
std::cout << "PASS automatic background OCR, persisted search and unchanged clipboard\n";
|
|
||||||
}
|
|
||||||
std::filesystem::remove_all(temp);
|
|
||||||
std::cout << (tesseract ? "PASS optional Tesseract, mixed Ukrainian/English and cache migration\n" :
|
|
||||||
"PASS native Windows OCR, DIB decoding and async result retrieval\n");
|
|
||||||
CoUninitialize();
|
CoUninitialize();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
param([string]$Executable = "$PSScriptRoot\..\build\staged\KSwitcher.Screenshot.exe")
|
|
||||||
$ErrorActionPreference='Stop'
|
|
||||||
if (Get-Process KSwitcher.Screenshot -ErrorAction SilentlyContinue) { throw 'Close existing screenshot editors before this test.' }
|
|
||||||
Add-Type @'
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
public static class ScreenshotProcessTest {
|
|
||||||
public delegate bool EnumProc(IntPtr w, IntPtr p);
|
|
||||||
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr param);
|
|
||||||
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
|
|
||||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr w, StringBuilder name, int count);
|
|
||||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr w, StringBuilder name, int count);
|
|
||||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w, uint msg, IntPtr wp, IntPtr lp);
|
|
||||||
public static IntPtr[] Windows(int process, string cls) {
|
|
||||||
var windows=new List<IntPtr>();
|
|
||||||
EnumWindows((w,p)=>{ uint pid; GetWindowThreadProcessId(w,out pid);
|
|
||||||
var name=new StringBuilder(256); GetClassName(w,name,256);
|
|
||||||
if(pid==process && name.ToString()==cls) windows.Add(w); return true;
|
|
||||||
},IntPtr.Zero); return windows.ToArray();
|
|
||||||
}
|
|
||||||
public static string Title(IntPtr w) { var s=new StringBuilder(256); GetWindowText(w,s,256); return s.ToString(); }
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
function Wait-For([scriptblock]$Check,[string]$Message) {
|
|
||||||
for($attempt=0;$attempt -lt 100;$attempt++) { if (& $Check) { return }; Start-Sleep -Milliseconds 50 }
|
|
||||||
throw $Message
|
|
||||||
}
|
|
||||||
function Send($Window,$Message,$Wparam=0,$Lparam=0) {
|
|
||||||
[ScreenshotProcessTest]::PostMessage($Window,$Message,[IntPtr]$Wparam,[IntPtr]$Lparam) | Out-Null
|
|
||||||
}
|
|
||||||
function Capture-Windows { @([ScreenshotProcessTest]::Windows($script:process.Id,'KSwitcher.Capture')) }
|
|
||||||
function Select-Region($Window) {
|
|
||||||
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
|
|
||||||
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
|
|
||||||
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
|
|
||||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).'
|
|
||||||
}
|
|
||||||
function Close-Editor($Window) {
|
|
||||||
Send $Window 0x10
|
|
||||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.'
|
|
||||||
}
|
|
||||||
function Latest-Token {
|
|
||||||
(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName
|
|
||||||
}
|
|
||||||
$process=$null
|
|
||||||
try {
|
|
||||||
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
|
||||||
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
|
|
||||||
Select-Region (Capture-Windows)[0]
|
|
||||||
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
|
|
||||||
$token=Latest-Token
|
|
||||||
$process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
|
||||||
Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.'
|
|
||||||
$first=(Capture-Windows)[0]
|
|
||||||
$secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
|
||||||
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' }
|
|
||||||
Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.'
|
|
||||||
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]
|
|
||||||
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
|
|
||||||
Close-Editor $first
|
|
||||||
if ($process.HasExited) { throw 'Process exited with an editor open.' }
|
|
||||||
Close-Editor $second
|
|
||||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
|
|
||||||
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
|
||||||
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
|
|
||||||
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
|
|
||||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
|
|
||||||
'Passed screenshot process lifecycle checks'
|
|
||||||
} finally {
|
|
||||||
if ($process -and !$process.HasExited) { Stop-Process -Id $process.Id }
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// Redirect HKCU and the shortcut destination; never replace the user's registration.
|
|
||||||
#include "../src/screenshot_notifications.h"
|
|
||||||
#include <shlobj.h>
|
|
||||||
#include <iostream>
|
|
||||||
static std::wstring programs;
|
|
||||||
static std::wstring activationExecutable, activationCommand;
|
|
||||||
static int activationCalls{};
|
|
||||||
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
|
||||||
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
|
||||||
{
|
|
||||||
++activationCalls; activationExecutable=path; activationCommand=command;
|
|
||||||
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
|
||||||
}
|
|
||||||
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
|
||||||
{
|
|
||||||
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
|
||||||
if(!*out) return E_OUTOFMEMORY;
|
|
||||||
wcscpy(*out,programs.c_str()); return S_OK;
|
|
||||||
}
|
|
||||||
#define SHGetKnownFolderPath testKnownFolder
|
|
||||||
#define CreateProcessW testCreateProcess
|
|
||||||
#include "../src/screenshot_activation.cpp"
|
|
||||||
#undef SHGetKnownFolderPath
|
|
||||||
#undef CreateProcessW
|
|
||||||
int main()
|
|
||||||
{
|
|
||||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
|
||||||
auto suffix=std::to_wstring(GetCurrentProcessId());
|
|
||||||
auto keyPath=L"Software\\KSwitcherRegistrationTest-"+suffix;
|
|
||||||
auto directory=std::filesystem::temp_directory_path()/(L"KSwitcherRegistrationTest-"+suffix);
|
|
||||||
std::filesystem::create_directories(directory); programs=directory.wstring();
|
|
||||||
HKEY isolated{}; int result=0;
|
|
||||||
try {
|
|
||||||
if(RegCreateKeyExW(HKEY_CURRENT_USER,keyPath.c_str(),0,nullptr,0,KEY_ALL_ACCESS,nullptr,&isolated,nullptr)!=ERROR_SUCCESS ||
|
|
||||||
RegOverridePredefKey(HKEY_CURRENT_USER,isolated)!=ERROR_SUCCESS) throw std::runtime_error("registry isolation failed");
|
|
||||||
ks::registerScreenshotNotifications();
|
|
||||||
wchar_t command[32768]{}; DWORD size=sizeof(command);
|
|
||||||
auto path=L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32";
|
|
||||||
if(RegGetValueW(HKEY_CURRENT_USER,path,nullptr,RRF_RT_REG_SZ,nullptr,command,&size)!=ERROR_SUCCESS)
|
|
||||||
throw std::runtime_error("COM server was not registered");
|
|
||||||
auto helper=std::filesystem::path(ks::executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
|
||||||
if(command!=L"\""+helper.wstring()+L"\" --toast-activated") throw std::runtime_error("COM server targets wrong executable");
|
|
||||||
Microsoft::WRL::ComPtr<IShellLinkW> link;
|
|
||||||
if(FAILED(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)))) throw std::runtime_error("read shortcut");
|
|
||||||
Microsoft::WRL::ComPtr<IPersistFile> file; link.As(&file);
|
|
||||||
if(FAILED(file->Load((directory/L"KSwitcher Screenshot.lnk").c_str(),STGM_READ))) throw std::runtime_error("shortcut missing");
|
|
||||||
wchar_t target[32768]{}; link->GetPath(target,32768,nullptr,SLGP_RAWPATH);
|
|
||||||
if(target!=helper.wstring()) throw std::runtime_error("shortcut targets wrong executable");
|
|
||||||
Microsoft::WRL::ComPtr<IPropertyStore> properties; link.As(&properties);
|
|
||||||
PROPVARIANT id{}; properties->GetValue(PKEY_AppUserModel_ID,&id);
|
|
||||||
bool valid=id.vt==VT_LPWSTR && std::wstring(id.pwszVal)==L"HrynCo.KSwitcher.Screenshot";
|
|
||||||
PropVariantClear(&id);
|
|
||||||
if(!valid) throw std::runtime_error("shortcut identity missing");
|
|
||||||
properties->GetValue(PKEY_AppUserModel_ToastActivatorCLSID,&id);
|
|
||||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
|
||||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
|
||||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
|
||||||
ks::Activator activator(helper.wstring(),false);
|
|
||||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
|
||||||
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
|
||||||
if(activationCalls!=1 || activationExecutable!=main || activationCommand!=L"\""+main+L"\" --check-update")
|
|
||||||
throw std::runtime_error("update toast must open review, never install automatically");
|
|
||||||
if(activator.Activate(L"other-app",L"check-update",nullptr,0)!=E_INVALIDARG ||
|
|
||||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"--install",nullptr,0)!=E_INVALIDARG || activationCalls!=1)
|
|
||||||
throw std::runtime_error("invalid notification activation must not launch a process");
|
|
||||||
const wchar_t *token=L"12345678-1234-1234-1234-123456789abc";
|
|
||||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",token,nullptr,0);
|
|
||||||
if(activationCalls!=2 || activationExecutable!=helper.wstring() || activationCommand.find(token)==activationCommand.npos)
|
|
||||||
throw std::runtime_error("screenshot activation must retain its existing route");
|
|
||||||
std::cout<<"Passed isolated screenshot registration checks\n";
|
|
||||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
|
||||||
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
|
||||||
if(isolated) RegCloseKey(isolated);
|
|
||||||
RegDeleteTreeW(HKEY_CURRENT_USER,keyPath.c_str());
|
|
||||||
std::filesystem::remove_all(directory);
|
|
||||||
CoUninitialize(); return result;
|
|
||||||
}
|
|
||||||
+6
-318
@@ -4,10 +4,10 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
|
||||||
try {
|
try {
|
||||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||||
ks::Capture c(app);
|
ks::Capture c(app);
|
||||||
c.image=std::make_unique<ks::Surface>(320,240);
|
c.image=std::make_unique<ks::Surface>(320,240);
|
||||||
@@ -18,111 +18,8 @@ int main()
|
|||||||
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
|
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
|
||||||
require(window!=nullptr,"editor creation");
|
require(window!=nullptr,"editor creation");
|
||||||
ks::edit(window,c); ShowWindow(window,SW_HIDE);
|
ks::edit(window,c); ShowWindow(window,SW_HIDE);
|
||||||
require(GetWindow(window,GW_OWNER)==nullptr && (GetWindowLongPtrW(window,GWL_EXSTYLE)&WS_EX_APPWINDOW),
|
|
||||||
"editor is an independent Alt+Tab application window");
|
|
||||||
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
||||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
|
||||||
"new screenshot editor starts with the pencil tool");
|
|
||||||
{
|
|
||||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
|
||||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
|
||||||
auto mouse=[&](UINT message,POINT p,bool shift=false) {
|
|
||||||
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
|
||||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
|
||||||
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
|
||||||
};
|
|
||||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{100,50},true);
|
|
||||||
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
|
||||||
mouse(WM_MOUSEMOVE,{100,90},true);
|
|
||||||
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
|
|
||||||
"50 pixel deviation keeps the current axis");
|
|
||||||
mouse(WM_MOUSEMOVE,{100,91},true);
|
|
||||||
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
|
|
||||||
"51 pixel deviation turns vertically and preserves the horizontal segment");
|
|
||||||
mouse(WM_MOUSEMOVE,{70,140});
|
|
||||||
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
|
|
||||||
"releasing Shift resumes freehand in the same stroke");
|
|
||||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
|
||||||
mouse(WM_MOUSEMOVE,{140,150},true);
|
|
||||||
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
|
|
||||||
"pressing Shift mid-stroke anchors at the last drawn point");
|
|
||||||
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
|
|
||||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
|
||||||
mouse(WM_MOUSEMOVE,{150,200},true);
|
|
||||||
mouse(WM_LBUTTONUP,{150,210},true);
|
|
||||||
SetKeyboardState(savedKeys);
|
|
||||||
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
|
|
||||||
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
|
|
||||||
SendMessageW(window,WM_COMMAND,12,0);
|
|
||||||
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
|
|
||||||
SendMessageW(window,WM_COMMAND,18,0);
|
|
||||||
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
|
||||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{160+sx,120},true);
|
|
||||||
for(int step=2;step<=90;++step)
|
|
||||||
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
|
|
||||||
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
|
|
||||||
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
|
|
||||||
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
|
|
||||||
for(int step=1;step<=90;++step)
|
|
||||||
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
|
|
||||||
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
|
|
||||||
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
|
|
||||||
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
|
|
||||||
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
}
|
|
||||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
|
||||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
|
|
||||||
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
|
|
||||||
"Shift snaps to exact 45 degree diagonals in all four directions");
|
|
||||||
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
}
|
|
||||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{100,40},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{160,100},true);
|
|
||||||
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
|
|
||||||
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
|
|
||||||
"large deviation turns a horizontal segment diagonally without losing its corner");
|
|
||||||
mouse(WM_MOUSEMOVE,{200,100},true);
|
|
||||||
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
|
|
||||||
"small perpendicular deviation keeps the diagonal axis");
|
|
||||||
mouse(WM_MOUSEMOVE,{260,120},true);
|
|
||||||
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
|
|
||||||
"diagonal deviation beyond 50 pixels turns back to horizontal");
|
|
||||||
mouse(WM_LBUTTONUP,{260,120},true);
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
|
||||||
POINT start{sx>0?310L:9L,120};
|
|
||||||
mouse(WM_LBUTTONDOWN,start,true);
|
|
||||||
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
|
|
||||||
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
|
|
||||||
auto end=c.stroke.to;
|
|
||||||
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
|
|
||||||
std::abs(end.x-start.x)==std::abs(end.y-start.y),
|
|
||||||
"diagonal stays at 45 degrees and inside image boundaries");
|
|
||||||
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
}
|
|
||||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
|
||||||
mouse(WM_LBUTTONDOWN,{40,40});
|
|
||||||
mouse(WM_MOUSEMOVE,{100,70});
|
|
||||||
mouse(WM_LBUTTONUP,{120,90});
|
|
||||||
SetKeyboardState(savedKeys);
|
|
||||||
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
|
|
||||||
c.marks[0].to.x==120 && c.marks[0].to.y==90,
|
|
||||||
"Ctrl keeps its existing drawing behavior without constraining the pencil");
|
|
||||||
c.marks.clear(); c.selected=-1;
|
|
||||||
}
|
|
||||||
for(int size:{32,48,64,96}) {
|
for(int size:{32,48,64,96}) {
|
||||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||||
ICONINFO info{};
|
ICONINFO info{};
|
||||||
@@ -132,58 +29,16 @@ int main()
|
|||||||
if(info.hbmColor) DeleteObject(info.hbmColor);
|
if(info.hbmColor) DeleteObject(info.hbmColor);
|
||||||
DestroyCursor(cursor);
|
DestroyCursor(cursor);
|
||||||
}
|
}
|
||||||
ks::Surface cursorPreview(784,448);
|
|
||||||
PatBlt(cursorPreview.dc,0,0,784,448,WHITENESS);
|
|
||||||
int row=0;
|
|
||||||
for(int size:{32,48,64,96}) {
|
|
||||||
int column=0;
|
|
||||||
for(int tool:ks::CursorTools) {
|
|
||||||
auto cursor=ks::makeToolCursor(tool,size); ICONINFO info{};
|
|
||||||
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"tool cursors support standard and high DPI");
|
|
||||||
require(info.xHotspot==UINT(MulDiv(7,size,32)) && info.yHotspot==info.xHotspot,"tool cursor hotspot stays on crosshair center");
|
|
||||||
BITMAP mask{}; GetObjectW(info.hbmMask,sizeof(mask),&mask);
|
|
||||||
require(mask.bmWidth==size && mask.bmHeight==size*2,"cursor transparency mask matches requested DPI");
|
|
||||||
if(column%2) {
|
|
||||||
RECT tile{column*112,row*112,(column+1)*112,(row+1)*112};
|
|
||||||
auto dark=CreateSolidBrush(RGB(40,40,40)); FillRect(cursorPreview.dc,&tile,dark); DeleteObject(dark);
|
|
||||||
}
|
|
||||||
DrawIconEx(cursorPreview.dc,column*112,row*112,cursor,size,size,0,nullptr,DI_NORMAL);
|
|
||||||
if(info.hbmMask) DeleteObject(info.hbmMask);
|
|
||||||
if(info.hbmColor) DeleteObject(info.hbmColor);
|
|
||||||
DestroyCursor(cursor); ++column;
|
|
||||||
}
|
|
||||||
++row;
|
|
||||||
}
|
|
||||||
auto dib=ks::screenshotClip(cursorPreview.dc,784,448,{}).formats[0].data;
|
|
||||||
BITMAPFILEHEADER bmp{}; bmp.bfType=0x4d42; bmp.bfOffBits=sizeof(bmp)+sizeof(BITMAPINFOHEADER); bmp.bfSize=DWORD(sizeof(bmp)+dib.size());
|
|
||||||
ks::Bytes preview(sizeof(bmp)); memcpy(preview.data(),&bmp,sizeof(bmp)); preview.insert(preview.end(),dib.begin(),dib.end());
|
|
||||||
ks::atomicWrite(L"build/cursor-preview.bmp",preview);
|
|
||||||
for(int tool:{27,26,21,10,11,22,23,24,25,19}) {
|
|
||||||
SendMessageW(window,WM_COMMAND,tool,0);
|
|
||||||
HCURSOR expected=nullptr;
|
|
||||||
if(tool==27) expected=LoadCursorW(nullptr,IDC_ARROW);
|
|
||||||
else if(tool==26) expected=LoadCursorW(nullptr,IDC_IBEAM);
|
|
||||||
else if(tool==21) expected=c.pencilCursor;
|
|
||||||
else for(size_t i=0;i<std::size(ks::CursorTools);++i) if(ks::CursorTools[i]==tool) expected=c.drawingCursors[i];
|
|
||||||
require(expected && ks::actionCursor(c,{-1,ks::MarkPart::None},true)==expected,"each tool uses its own drawing cursor");
|
|
||||||
require(ks::actionCursor(c,{-1,ks::MarkPart::None},false)==LoadCursorW(nullptr,IDC_ARROW),"outside canvas uses normal pointer");
|
|
||||||
c.marks={{{20,20},{120,20},0}};
|
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
|
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
|
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEWE),"endpoint resize overrides drawing tool");
|
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
|
|
||||||
}
|
|
||||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
|
||||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
|
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no usage hints");
|
||||||
RECT client{}; GetClientRect(window,&client);
|
RECT client{}; GetClientRect(window,&client);
|
||||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||||
ks::updateStatus(c);
|
ks::updateStatus(c);
|
||||||
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"default tool hint is shown");
|
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
|
||||||
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
||||||
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
||||||
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
||||||
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"button leave restores contextual hint");
|
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
|
||||||
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
||||||
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
||||||
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
||||||
@@ -237,124 +92,6 @@ int main()
|
|||||||
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
|
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
|
||||||
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
|
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
|
||||||
c.hoveredPart=ks::MarkPart::None;
|
c.hoveredPart=ks::MarkPart::None;
|
||||||
// Drag the two badges in turn, including a click near the attached endpoint.
|
|
||||||
for(bool freehand : {false,true}) for(auto ends : {ks::LineEnds::End,ks::LineEnds::None}) {
|
|
||||||
c.marks={{{60,80},{60,80},1},{{230,80},{230,80},2},{{60,80},{230,80},0}};
|
|
||||||
auto &connector=c.marks[2]; connector.fromNumber=1; connector.toNumber=2;
|
|
||||||
connector.ends=ends; connector.freehand=freehand;
|
|
||||||
if(freehand) connector.points={{60,80},{140,80},{230,80}};
|
|
||||||
ks::resolveScreenshotLinks(c.marks); c.selected=-1; c.selectedLabel=false;
|
|
||||||
auto mouse=[&](UINT message,POINT p) {
|
|
||||||
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
|
||||||
LONG y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
|
||||||
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
|
||||||
};
|
|
||||||
for(int badge : {0,1,0,1}) {
|
|
||||||
auto origin=c.marks[badge].from;
|
|
||||||
// The second badge's inner edge overlaps the connector endpoint hit area.
|
|
||||||
if(badge==1) origin.x-=12;
|
|
||||||
mouse(WM_LBUTTONDOWN,origin);
|
|
||||||
require(c.selected==badge && c.part==ks::MarkPart::Body,"click on linked badge selects badge instead of connector endpoint");
|
|
||||||
mouse(WM_MOUSEMOVE,{origin.x,origin.y+20});
|
|
||||||
mouse(WM_LBUTTONUP,{origin.x,origin.y+20});
|
|
||||||
require(c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,"alternating badge drags preserve both connector links");
|
|
||||||
auto start=c.marks[0].from,end=c.marks[1].from;
|
|
||||||
require(std::abs(std::hypot(double(c.marks[2].from.x-start.x),double(c.marks[2].from.y-start.y))-15)<1 &&
|
|
||||||
std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.y))-15)<1,
|
|
||||||
"connector endpoints follow both badge rims");
|
|
||||||
}
|
|
||||||
// Selecting a linked connector (also the first click of a double-click)
|
|
||||||
// must not detach it before opening its caption editor.
|
|
||||||
for(int entry : {0,1,2}) {
|
|
||||||
c.selected=-1;
|
|
||||||
POINT body{110,c.marks[0].from.y};
|
|
||||||
if(freehand) body={(c.marks[2].points[0].x+c.marks[2].points[1].x)/2,
|
|
||||||
(c.marks[2].points[0].y+c.marks[2].points[1].y)/2};
|
|
||||||
mouse(WM_LBUTTONDOWN,body); mouse(WM_LBUTTONUP,body);
|
|
||||||
require(c.selected==2 && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
|
||||||
"clicking a connector without moving preserves both links");
|
|
||||||
if(entry==0) { mouse(WM_LBUTTONDBLCLK,body); mouse(WM_LBUTTONUP,body); }
|
|
||||||
else if(entry==1) SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
|
||||||
else SendMessageW(window,WM_COMMAND,35,0);
|
|
||||||
require(c.inlineText && c.editingLabel,"linked connector caption opens through every entry point");
|
|
||||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Linked caption");
|
|
||||||
ks::finishInlineText(window,c,true);
|
|
||||||
require(c.marks[2].label==L"Linked caption" && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
|
||||||
"adding or editing a caption preserves both connector links");
|
|
||||||
}
|
|
||||||
c.selected=2;
|
|
||||||
require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click");
|
|
||||||
auto outside=c.marks[2].to; outside.x-=4;
|
|
||||||
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
auto mouse=[&](UINT message,POINT p) {
|
|
||||||
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
|
||||||
LONG y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
|
||||||
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
|
||||||
};
|
|
||||||
SendMessageW(window,WM_COMMAND,27,0); c.marks.clear();
|
|
||||||
mouse(WM_LBUTTONDOWN,{40,160}); mouse(WM_MOUSEMOVE,{100,190}); mouse(WM_LBUTTONUP,{100,190});
|
|
||||||
require(c.marks.empty() && !c.dragging && c.selected==-1,"pointer drag on empty canvas never adds an object");
|
|
||||||
for(int tool : {11,22}) for(int target : {0,1,2}) {
|
|
||||||
if(target==0) c.marks={{{55,160},{55,160},1},{{255,160},{255,160},2}};
|
|
||||||
else {
|
|
||||||
c.marks={{{30,140},{80,190},0},{{230,140},{280,190},0}};
|
|
||||||
if(target==1) for(auto &mark:c.marks) mark.shape=ks::ScreenshotShape::Rectangle;
|
|
||||||
else for(auto &mark:c.marks) mark.from.y=mark.to.y=160;
|
|
||||||
}
|
|
||||||
SendMessageW(window,WM_COMMAND,tool,0);
|
|
||||||
BYTE savedKeys[256]{},drawingKeys[256]{};
|
|
||||||
require(GetKeyboardState(savedKeys)!=FALSE,"read thread keyboard state");
|
|
||||||
std::copy(savedKeys,savedKeys+256,drawingKeys); drawingKeys[VK_CONTROL]=0x80;
|
|
||||||
require(SetKeyboardState(drawingKeys)!=FALSE,"set thread-local Ctrl for drawing test");
|
|
||||||
mouse(WM_LBUTTONDOWN,{55,160}); mouse(WM_MOUSEMOVE,{255,160}); mouse(WM_LBUTTONUP,{255,160});
|
|
||||||
SetKeyboardState(savedKeys);
|
|
||||||
require(c.marks.size()==3,"drawing over existing objects creates a connector with Ctrl");
|
|
||||||
auto &connector=c.marks[2];
|
|
||||||
require(target==0?(connector.fromNumber==1 && connector.toNumber==2):
|
|
||||||
(connector.fromLink.target==c.marks[0].id && connector.toLink.target==c.marks[1].id),
|
|
||||||
"new arrow or line automatically attaches both endpoints to targets");
|
|
||||||
require(connector.ends==(tool==11?c.arrowEnds:ks::LineEnds::None),"arrow and straight-line tools retain their end styles");
|
|
||||||
auto before=connector.from;
|
|
||||||
SendMessageW(window,WM_KEYDOWN,'V',0);
|
|
||||||
require(c.pointer && !c.arrow && !c.freehand,"V selects pointer mode");
|
|
||||||
POINT body=target==0?POINT{55,160}:target==1?POINT{30,175}:POINT{30,160};
|
|
||||||
mouse(WM_LBUTTONDOWN,body); mouse(WM_MOUSEMOVE,{body.x,body.y-20}); mouse(WM_LBUTTONUP,{body.x,body.y-20});
|
|
||||||
require(c.selected==0 && c.marks.size()==3 && c.marks[2].from.y<before.y,
|
|
||||||
"pointer moves the target and the new connector follows without adding objects");
|
|
||||||
mouse(WM_LBUTTONDOWN,{150,220}); mouse(WM_LBUTTONUP,{150,220});
|
|
||||||
require(c.selected==-1 && c.marks.size()==3,"pointer click on empty space only clears selection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(bool manualAngle : {false,true}) {
|
|
||||||
c.marks={{{40,140},{180,60},0}}; c.selected=0;
|
|
||||||
c.marks[0].fontSize=18; c.marks[0].fontWeight=FW_BOLD;
|
|
||||||
c.marks[0].rotation=31; c.marks[0].labelRotationManual=manualAngle;
|
|
||||||
SendMessageW(window,WM_COMMAND,35,0);
|
|
||||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Sloped caption");
|
|
||||||
ks::finishInlineText(window,c,true);
|
|
||||||
auto captionAngle=ks::screenshotArrowLabel(c.marks[0]).rotation;
|
|
||||||
require(captionAngle!=0,"caption retains its automatic or manual angle");
|
|
||||||
SendMessageW(window,WM_COMMAND,26,0);
|
|
||||||
LONG x=c.view.left+MulDiv(220,c.view.right-c.view.left,c.image->width);
|
|
||||||
LONG y=c.view.top+MulDiv(190,c.view.bottom-c.view.top,c.image->height);
|
|
||||||
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(x,y));
|
|
||||||
require(c.inlineText && !c.editingLabel,"standalone text starts independently of caption");
|
|
||||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Independent text");
|
|
||||||
ks::finishInlineText(window,c,true);
|
|
||||||
require(c.marks.size()==2 && c.marks[1].isText() && c.marks[1].rotation==0,
|
|
||||||
"new standalone text does not inherit caption rotation");
|
|
||||||
require(c.marks[1].fontSize==18 && c.marks[1].fontWeight==FW_BOLD,
|
|
||||||
"new text still inherits font styling");
|
|
||||||
require(ks::screenshotArrowLabel(c.marks[0]).rotation==captionAngle,"new text does not change caption angle");
|
|
||||||
c.marks[1].rotation=22; c.selected=1;
|
|
||||||
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
|
||||||
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited standalone text");
|
|
||||||
ks::finishInlineText(window,c,true);
|
|
||||||
require(c.marks[1].rotation==22,"editing existing rotated text preserves its own angle");
|
|
||||||
SendMessageW(window,WM_COMMAND,27,0);
|
|
||||||
}
|
|
||||||
std::vector<ks::Mark> nudgeMarks;
|
std::vector<ks::Mark> nudgeMarks;
|
||||||
nudgeMarks.push_back({{60,60},{60,60},1});
|
nudgeMarks.push_back({{60,60},{60,60},1});
|
||||||
nudgeMarks.push_back({{60,60},{120,100},0});
|
nudgeMarks.push_back({{60,60},{120,100},0});
|
||||||
@@ -387,18 +124,6 @@ int main()
|
|||||||
SendMessageW(window,WM_COMMAND,18,0);
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
|
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
|
||||||
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
|
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
|
||||||
ks::copy(window,c,[](HWND,const ks::Clip &clip)->DWORD {
|
|
||||||
if(clip.formats.empty()) throw std::runtime_error("copy produced no image");
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
require(IsWindow(window) && c.copied,"copy publishes screenshot and keeps editor open");
|
|
||||||
c.selected=0; c.selectedLabel=false;
|
|
||||||
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
|
|
||||||
require(!c.copied,"editing after copy requires confirmation again");
|
|
||||||
c.copied=true; SendMessageW(window,WM_COMMAND,12,0);
|
|
||||||
require(!c.copied,"undo after copy requires confirmation again");
|
|
||||||
c.copied=true; SendMessageW(window,WM_COMMAND,18,0);
|
|
||||||
require(!c.copied,"redo after copy requires confirmation again");
|
|
||||||
SetPropW(window,L"ConfirmationCentered",nullptr);
|
SetPropW(window,L"ConfirmationCentered",nullptr);
|
||||||
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
|
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
|
||||||
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
|
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
|
||||||
@@ -424,45 +149,8 @@ int main()
|
|||||||
require(IsWindow(window),"Escape does not close the screenshot editor");
|
require(IsWindow(window),"Escape does not close the screenshot editor");
|
||||||
SendMessageW(window,WM_CLOSE,0,0);
|
SendMessageW(window,WM_CLOSE,0,0);
|
||||||
require(!IsWindow(window),"copied screenshot closes without confirmation");
|
require(!IsWindow(window),"copied screenshot closes without confirmation");
|
||||||
static bool published=false;
|
|
||||||
static bool notificationAccepted=false;
|
|
||||||
static HWND notificationWindow{};
|
|
||||||
static bool expectHidden=false;
|
|
||||||
app.notify=[](const ks::Clip &) {
|
|
||||||
if(!published) throw std::runtime_error("notification preceded clipboard publication");
|
|
||||||
if(expectHidden && IsWindowVisible(notificationWindow))
|
|
||||||
throw std::runtime_error("selection overlay is still visible when notification is sent");
|
|
||||||
return notificationAccepted;
|
|
||||||
};
|
|
||||||
for(bool autoOpen : {true,false}) for (bool accepted : {true, false}) {
|
|
||||||
app.autoOpenEditor=autoOpen;
|
|
||||||
published=false;
|
|
||||||
notificationAccepted=accepted;
|
|
||||||
ks::Capture selection(app);
|
|
||||||
selection.image=std::make_unique<ks::Surface>(100,100);
|
|
||||||
PatBlt(selection.image->dc,0,0,100,100,WHITENESS);
|
|
||||||
selection.start={10,20}; selection.end={70,60};
|
|
||||||
auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection);
|
|
||||||
notificationWindow=overlay; expectHidden=!autoOpen;
|
|
||||||
ShowWindow(overlay,SW_SHOWNOACTIVATE);
|
|
||||||
ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD {
|
|
||||||
auto pixels=ks::decodeImage(clip);
|
|
||||||
if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection");
|
|
||||||
published=true; return 0;
|
|
||||||
});
|
|
||||||
if(!autoOpen && accepted) {
|
|
||||||
require(published && !IsWindow(overlay),"notification-first closes selection after successful submission");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
require(published && selection.copied && IsWindowVisible(overlay) && selection.editing,
|
|
||||||
"selection opens editor whether notification is accepted or rejected");
|
|
||||||
require(selection.image->width==60 && selection.image->height==40,
|
|
||||||
"automatically opened editor contains the selected region");
|
|
||||||
SendMessageW(overlay,WM_CLOSE,0,0);
|
|
||||||
require(!IsWindow(overlay),"automatically copied screenshot closes without confirmation");
|
|
||||||
}
|
|
||||||
DestroyWindow(app.window); app.window=nullptr;
|
DestroyWindow(app.window); app.window=nullptr;
|
||||||
CoUninitialize();
|
std::filesystem::remove_all(temp);
|
||||||
std::cout<<"Passed screenshot editor UI integration checks\n";
|
std::cout<<"Passed screenshot editor UI integration checks\n";
|
||||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||||
}
|
}
|
||||||
|
|||||||
-183
@@ -2,7 +2,6 @@
|
|||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include "diagnostics.h"
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -45,34 +44,6 @@ int main(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
{
|
|
||||||
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
|
||||||
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
|
||||||
{"problem", true}, {"verification", "read-back-via-accessibility"},
|
|
||||||
{"original", "hello\n\"quoted\""}, {"expected", ks::utf8(L"привіт")}, {"actual", "hello"},
|
|
||||||
{"context", "copy\npaste"}};
|
|
||||||
auto encoded = L"2026-10-31 12:00:00 Operation " + ks::wide(record.dump());
|
|
||||||
auto rows = ks::parseOperationLog(encoded);
|
|
||||||
require(rows.size() == 1 && rows[0].process == L"editor.exe" && rows[0].problem,
|
|
||||||
"structured operation is one filterable row");
|
|
||||||
require(rows[0].details.find(L"привіт") != std::wstring::npos &&
|
|
||||||
rows[0].details.find(L"copy\r\npaste") != std::wstring::npos,
|
|
||||||
"operation details preserve Unicode and normalize multiline text");
|
|
||||||
record["problem"] = false;
|
|
||||||
record["outcome"] = "Paste verified";
|
|
||||||
rows = ks::parseOperationLog(encoded + L"\n2026-10-31 12:00:01 Operation " + ks::wide(record.dump()));
|
|
||||||
require(rows.size() == 2 && !rows[1].problem, "verified operations remain distinguishable from problems");
|
|
||||||
rows = ks::parseOperationLog(L"old incident\n first stage\n second stage\n2026-10-31 Operation {broken");
|
|
||||||
require(rows.size() == 2 && rows[0].details.find(L"second stage") != std::wstring::npos &&
|
|
||||||
rows[1].details.find(L"{broken") != std::wstring::npos,
|
|
||||||
"legacy context is grouped and malformed records remain inspectable");
|
|
||||||
require(ks::conversionReadbackOutcome(L"new", L"old", L"new") == L"Paste verified",
|
|
||||||
"expected readback verifies paste");
|
|
||||||
require(ks::conversionReadbackOutcome(L"old", L"old", L"new") == L"Original text remained",
|
|
||||||
"original readback identifies unchanged editor text");
|
|
||||||
require(ks::conversionReadbackOutcome(L"other", L"old", L"new") == L"Readback differs",
|
|
||||||
"unexpected readback is never reported as success");
|
|
||||||
}
|
|
||||||
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
|
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
|
||||||
"diagnostic word preserved");
|
"diagnostic word preserved");
|
||||||
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
||||||
@@ -270,54 +241,6 @@ int main(int argc, char **argv)
|
|||||||
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
|
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
|
||||||
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
|
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
|
||||||
ks::AutoCorrector missing;
|
ks::AutoCorrector missing;
|
||||||
{
|
|
||||||
ks::AutoCorrector supplemental;
|
|
||||||
auto options = autoSettings;
|
|
||||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
|
||||||
options.cycle = {L"en", L"uk", L"ru"};
|
|
||||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
|
||||||
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
|
||||||
{
|
|
||||||
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
|
||||||
require(shared && shared->text == expected && shared->target == 2,
|
|
||||||
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
|
|
||||||
}
|
|
||||||
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
|
|
||||||
"different dictionary results remain ambiguous");
|
|
||||||
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
|
||||||
require(release && release->text == L"реліз " && release->target == 2,
|
|
||||||
"supplemental Ukrainian release word corrects from Russian layout");
|
|
||||||
require(!supplemental.suggest(L"реліз ", 2, layouts, options),
|
|
||||||
"correct Ukrainian release word stays unchanged");
|
|
||||||
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
|
|
||||||
"valid Russian release word stays unchanged");
|
|
||||||
options.autoMaxRank = 50000;
|
|
||||||
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
|
||||||
"supplemental vocabulary respects explicit frequency limit");
|
|
||||||
options.autoWords[L"uk"] = {L"релізик"};
|
|
||||||
options.autoMaxRank = 1;
|
|
||||||
auto custom = supplemental.suggest(L"релызик ", 1, layouts, options);
|
|
||||||
require(custom && custom->text == L"релізик " && custom->target == 2,
|
|
||||||
"personal word enables correction without editing dictionary files");
|
|
||||||
require(!supplemental.suggest(L"релізик ", 2, layouts, options),
|
|
||||||
"personal word is protected from correction");
|
|
||||||
options.autoNever = {L"релызик"};
|
|
||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
|
||||||
"never-correct list takes precedence over personal word");
|
|
||||||
options.autoNever.clear();
|
|
||||||
options.autoWords[L"uk"].push_back(L"робить");
|
|
||||||
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
|
||||||
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
|
||||||
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
|
||||||
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
|
||||||
"punctuation-key correction still rejects digits");
|
|
||||||
options.autoMinLength = 1;
|
|
||||||
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
|
||||||
"known English ye remains protected at minimum length one");
|
|
||||||
options.cycle = {L"en", L"ru"};
|
|
||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
|
||||||
"personal dictionary respects disabled target layouts");
|
|
||||||
}
|
|
||||||
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
|
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
|
||||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||||
@@ -330,12 +253,6 @@ int main(int argc, char **argv)
|
|||||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||||
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
||||||
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
|
|
||||||
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
|
|
||||||
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
|
|
||||||
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
|
|
||||||
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
|
|
||||||
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
|
|
||||||
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
||||||
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||||
@@ -418,25 +335,6 @@ int main(int argc, char **argv)
|
|||||||
auto temp = std::filesystem::temp_directory_path() /
|
auto temp = std::filesystem::temp_directory_path() /
|
||||||
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
|
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
std::filesystem::create_directories(temp);
|
std::filesystem::create_directories(temp);
|
||||||
{
|
|
||||||
auto folder = temp / L"dictionaries";
|
|
||||||
std::filesystem::create_directories(folder);
|
|
||||||
auto put = [&](const wchar_t *name, const std::wstring &text)
|
|
||||||
{
|
|
||||||
auto data = ks::utf8(text);
|
|
||||||
ks::atomicWrite(folder / name, ks::Bytes(data.begin(), data.end()));
|
|
||||||
};
|
|
||||||
put(L"en.txt", L"zoo\t3\nhello\t99\nhello\t1\n");
|
|
||||||
put(L"uk.txt", L"дім\t3\n");
|
|
||||||
ks::AutoCorrector edited;
|
|
||||||
edited.configure(folder, {L"en", L"uk"});
|
|
||||||
auto options = ks::Settings{};
|
|
||||||
options.cycle = {L"en", L"uk"};
|
|
||||||
options.autoMaxRank = 1;
|
|
||||||
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
|
||||||
require(correction && correction->text == L"hello ",
|
|
||||||
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
|
||||||
}
|
|
||||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||||
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
||||||
@@ -486,16 +384,11 @@ int main(int argc, char **argv)
|
|||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
settings.autoSuffixChars = L");%";
|
settings.autoSuffixChars = L");%";
|
||||||
settings.autoNever = {L"myWord"};
|
settings.autoNever = {L"myWord"};
|
||||||
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
|
|
||||||
settings.autoExcluded = {L"Code.exe"};
|
settings.autoExcluded = {L"Code.exe"};
|
||||||
settings.cycle = {L"en", L"uk"};
|
settings.cycle = {L"en", L"uk"};
|
||||||
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
||||||
ks::saveSettings(temp / L"settings.json", settings);
|
ks::saveSettings(temp / L"settings.json", settings);
|
||||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||||
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
|
||||||
auto invalidWords = settings;
|
|
||||||
invalidWords.autoWords[L"uk"] = {L"hello"};
|
|
||||||
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
|
||||||
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
|
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
|
||||||
"configurator snapshot survives serialization without changing the concurrency token");
|
"configurator snapshot survives serialization without changing the concurrency token");
|
||||||
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
|
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
|
||||||
@@ -537,13 +430,6 @@ int main(int argc, char **argv)
|
|||||||
auto invalid = settings;
|
auto invalid = settings;
|
||||||
auto captureSettings = settings;
|
auto captureSettings = settings;
|
||||||
captureSettings.screenshot = L"Ctrl+Shift+F9";
|
captureSettings.screenshot = L"Ctrl+Shift+F9";
|
||||||
require(!captureSettings.screenshotAutoOpenEditor,"notification-first capture is the default");
|
|
||||||
captureSettings.screenshotAutoOpenEditor=true;
|
|
||||||
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
|
|
||||||
"explicit automatic editor preference survives settings roundtrip");
|
|
||||||
captureSettings.screenshotAutoOpenEditor=false;
|
|
||||||
require(!ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
|
|
||||||
"notification-first preference survives settings roundtrip");
|
|
||||||
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
|
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
|
||||||
"screen capture shortcut roundtrip");
|
"screen capture shortcut roundtrip");
|
||||||
captureSettings.screenshot = captureSettings.clipboard;
|
captureSettings.screenshot = captureSettings.clipboard;
|
||||||
@@ -575,75 +461,6 @@ int main(int argc, char **argv)
|
|||||||
history.clear();
|
history.clear();
|
||||||
require(history.entries().empty(), "clear history");
|
require(history.entries().empty(), "clear history");
|
||||||
}
|
}
|
||||||
{
|
|
||||||
ks::History history(temp / L"search");
|
|
||||||
history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings);
|
|
||||||
history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings);
|
|
||||||
require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview");
|
|
||||||
require(history.search(L"first\nsecond").size() == 1, "search retains original newlines");
|
|
||||||
require(history.search(L"second\tthird").size() == 1, "search retains original tabs");
|
|
||||||
require(history.search(L"absent").empty(), "short previews reject unmatched queries");
|
|
||||||
require(history.search(L"FIRST").size() == 1, "short preview search ignores case");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
auto folder = temp / L"ocr-history";
|
|
||||||
ks::History history(folder);
|
|
||||||
ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}};
|
|
||||||
history.add(image, L"OCR test", settings);
|
|
||||||
auto id = history.entries().front().id;
|
|
||||||
require(history.entries().front().ocrState == 1, "new images await background OCR");
|
|
||||||
require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart");
|
|
||||||
auto original = ks::encodeClip(history.load(id));
|
|
||||||
auto beforeBytes = history.entries().front().bytes;
|
|
||||||
require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR");
|
|
||||||
require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size");
|
|
||||||
require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats");
|
|
||||||
require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten");
|
|
||||||
ks::History restored(folder);
|
|
||||||
require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable");
|
|
||||||
require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart");
|
|
||||||
auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024);
|
|
||||||
require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted");
|
|
||||||
restored.remove(id);
|
|
||||||
require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar");
|
|
||||||
require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image");
|
|
||||||
restored.add(image, L"OCR test", settings);
|
|
||||||
id = restored.entries().front().id;
|
|
||||||
restored.saveOcr(id, L"", settings);
|
|
||||||
require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result");
|
|
||||||
restored.clear();
|
|
||||||
require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars");
|
|
||||||
restored.add(image, L"OCR test", settings);
|
|
||||||
id = restored.entries().front().id;
|
|
||||||
restored.saveOcr(id, std::nullopt, settings);
|
|
||||||
require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart");
|
|
||||||
restored.saveOcr(id, L"manual retry", settings);
|
|
||||||
require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR");
|
|
||||||
restored.add(ks::textClip(L"newer"), L"test", settings);
|
|
||||||
auto limited = settings; limited.maxItems = 1;
|
|
||||||
restored.add(ks::textClip(L"newest"), L"test", limited);
|
|
||||||
require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
ks::Settings options;
|
|
||||||
require(!options.useTesseract, "Tesseract is opt-in by default");
|
|
||||||
options.useTesseract = true;
|
|
||||||
options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe";
|
|
||||||
options.tesseractLanguages = L"ukr+eng+rus";
|
|
||||||
auto restored = ks::decodeSettings(ks::encodeSettings(options), {});
|
|
||||||
require(restored.useTesseract && restored.tesseractPath == options.tesseractPath &&
|
|
||||||
restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip");
|
|
||||||
options.tesseractLanguages = L"ukr+../eng";
|
|
||||||
rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected");
|
|
||||||
options.tesseractLanguages = L"ukr++eng";
|
|
||||||
rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected");
|
|
||||||
options.tesseractLanguages = L"ukr+eng";
|
|
||||||
options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe";
|
|
||||||
rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure");
|
|
||||||
options.useTesseract = false;
|
|
||||||
ks::validateTesseract(options);
|
|
||||||
require(true, "Windows OCR works without optional installation");
|
|
||||||
}
|
|
||||||
// Test-owned directory only; never touch the user's clipboard or stored history.
|
// Test-owned directory only; never touch the user's clipboard or stored history.
|
||||||
std::filesystem::remove_all(temp);
|
std::filesystem::remove_all(temp);
|
||||||
CoUninitialize();
|
CoUninitialize();
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ try {
|
|||||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
||||||
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
|
|
||||||
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
|
|
||||||
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
|
|
||||||
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
|
|
||||||
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
|
|
||||||
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
|
|
||||||
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
||||||
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
||||||
@@ -30,8 +24,6 @@ try {
|
|||||||
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
||||||
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
||||||
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
||||||
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
|
|
||||||
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
|
|
||||||
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
||||||
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
||||||
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
|
||||||
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
|
|
||||||
Start-UpdateProgress
|
|
||||||
try {
|
|
||||||
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
|
|
||||||
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
|
|
||||||
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
|
|
||||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
|
|
||||||
if ($Network) {
|
|
||||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
|
|
||||||
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
|
|
||||||
}
|
|
||||||
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
|
|
||||||
Show-UpdateProgress $stage 'Update stage under test'
|
|
||||||
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
|
|
||||||
}
|
|
||||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
|
|
||||||
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
|
|
||||||
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
|
|
||||||
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
|
|
||||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
|
|
||||||
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
|
|
||||||
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
|
|
||||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
|
|
||||||
'Passed updater progress UI checks'
|
|
||||||
} finally {
|
|
||||||
$script:progressWindow.Dispose()
|
|
||||||
$script:progressWindow = $null
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
param([Parameter(Mandatory)][string]$BridgeArchive,[Parameter(Mandatory)][string]$FullArchive)
|
|
||||||
$ErrorActionPreference='Stop'
|
|
||||||
Add-Type @'
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
public static class UpdateResource {
|
|
||||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags);
|
|
||||||
[DllImport("kernel32.dll")] static extern IntPtr FindResource(IntPtr module, IntPtr name, IntPtr type);
|
|
||||||
[DllImport("kernel32.dll")] static extern IntPtr LoadResource(IntPtr module, IntPtr resource);
|
|
||||||
[DllImport("kernel32.dll")] static extern IntPtr LockResource(IntPtr resource);
|
|
||||||
[DllImport("kernel32.dll")] static extern uint SizeofResource(IntPtr module, IntPtr resource);
|
|
||||||
[DllImport("kernel32.dll")] static extern bool FreeLibrary(IntPtr module);
|
|
||||||
public static byte[] Read(string path) {
|
|
||||||
var module=LoadLibraryEx(path,IntPtr.Zero,2|32);
|
|
||||||
if(module==IntPtr.Zero) throw new Exception("Cannot load bridge resources");
|
|
||||||
try {
|
|
||||||
var resource=FindResource(module,(IntPtr)201,(IntPtr)10);
|
|
||||||
var size=SizeofResource(module,resource);
|
|
||||||
if(size==0 || size>1024*1024) throw new Exception("Invalid bridge updater resource");
|
|
||||||
var bytes=new byte[size];
|
|
||||||
Marshal.Copy(LockResource(LoadResource(module,resource)),bytes,0,(int)size);
|
|
||||||
return bytes;
|
|
||||||
} finally { FreeLibrary(module); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-transition-'+[guid]::NewGuid().ToString('N'))
|
|
||||||
[IO.Directory]::CreateDirectory($root) | Out-Null
|
|
||||||
try {
|
|
||||||
$legacy=Join-Path $root 'legacy.ps1'
|
|
||||||
$oldSource=git -C (Join-Path $PSScriptRoot '..') show v0.4.1:resources/update.ps1
|
|
||||||
if ($LASTEXITCODE) { throw 'Cannot read the released v0.4.1 updater.' }
|
|
||||||
$oldSource | Set-Content -LiteralPath $legacy -Encoding utf8
|
|
||||||
$releases=@('v0.4.2','release-v0.5.0') | ForEach-Object {
|
|
||||||
@{tag_name=$_;draft=$false;prerelease=$false;assets=@(
|
|
||||||
@{name='KSwitcher-win-x64.zip';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/KSwitcher-win-x64.zip"},
|
|
||||||
@{name='SHA256SUMS.txt';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/SHA256SUMS.txt"})}
|
|
||||||
}
|
|
||||||
$install=Join-Path $root 'install'
|
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $install 'data')) | Out-Null
|
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $install 'dictionaries')) | Out-Null
|
|
||||||
[IO.File]::WriteAllText((Join-Path $install 'data/settings.json'),'personal settings')
|
|
||||||
[IO.File]::WriteAllText((Join-Path $install 'dictionaries/en.txt'),'personal dictionary')
|
|
||||||
& {
|
|
||||||
. $legacy -Library
|
|
||||||
$selected=Select-Release $releases ([version]'0.4.1')
|
|
||||||
if ($selected.version -ne 'v0.4.2') { throw 'Legacy client skipped the bridge.' }
|
|
||||||
$stage=Join-Path $root 'bridge-stage'
|
|
||||||
Expand-Release $BridgeArchive $stage ([version]'0.4.2')
|
|
||||||
$changed=[Collections.Generic.List[string]]::new()
|
|
||||||
Install-Files $stage $install (Join-Path $root 'bridge-backup') $changed
|
|
||||||
}
|
|
||||||
$bridgeHelper=Join-Path $root 'bridge-helper.ps1'
|
|
||||||
[IO.File]::WriteAllBytes($bridgeHelper,[UpdateResource]::Read((Join-Path $install 'KSwitcher.exe')))
|
|
||||||
. $bridgeHelper -Library
|
|
||||||
$selected=Select-Release $releases ([version]'0.4.2')
|
|
||||||
if ($selected.version -ne 'release-v0.5.0') { throw 'Bridge did not find the full release.' }
|
|
||||||
$stage=Join-Path $root 'full-stage'
|
|
||||||
Expand-Release $FullArchive $stage ([version]'0.5.0')
|
|
||||||
$changed=[Collections.Generic.List[string]]::new()
|
|
||||||
Install-Files $stage $install (Join-Path $root 'full-backup') $changed
|
|
||||||
foreach ($name in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
|
|
||||||
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install $name)).FileVersion
|
|
||||||
if ((Get-StableVersion $version) -ne [version]'0.5.0') { throw "Incorrect installed version: $name" }
|
|
||||||
}
|
|
||||||
if ([IO.File]::ReadAllText((Join-Path $install 'data/settings.json')) -ne 'personal settings' -or
|
|
||||||
[IO.File]::ReadAllText((Join-Path $install 'dictionaries/en.txt')) -ne 'personal dictionary') { throw 'User data changed during transition.' }
|
|
||||||
if (Select-Release $releases ([version]'0.5.0')) { throw 'Current version offers another update.' }
|
|
||||||
Undo-Files $install (Join-Path $root 'full-backup') $changed
|
|
||||||
if ((Get-StableVersion ([Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install 'KSwitcher.exe')).FileVersion)) -ne [version]'0.4.2' -or
|
|
||||||
(Test-Path -LiteralPath (Join-Path $install 'KSwitcher.Screenshot.exe'))) { throw 'Full release rollback did not restore the bridge.' }
|
|
||||||
'Passed real-package transition: v0.4.1 updater -> v0.4.2 bridge -> release-v0.5.0; user data preserved; rollback verified.'
|
|
||||||
} finally {
|
|
||||||
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-transition-*') {
|
|
||||||
Remove-Item -LiteralPath $root -Recurse -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ from pathlib import Path
|
|||||||
import urllib.request, hashlib, json
|
import urllib.request, hashlib, json
|
||||||
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
||||||
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
||||||
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
|
|
||||||
SUPPLEMENTAL={'uk': {'реліз': 50001}}
|
|
||||||
ROOT.mkdir(exist_ok=True)
|
ROOT.mkdir(exist_ok=True)
|
||||||
manifest={}
|
manifest={}
|
||||||
for lang in ('en','uk','ru'):
|
for lang in ('en','uk','ru'):
|
||||||
@@ -17,12 +15,8 @@ for lang in ('en','uk','ru'):
|
|||||||
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
|
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
|
||||||
if int(count)<5:continue
|
if int(count)<5:continue
|
||||||
rows.setdefault(word,rank)
|
rows.setdefault(word,rank)
|
||||||
for word,rank in SUPPLEMENTAL.get(lang,{}).items():
|
|
||||||
rows.setdefault(word,rank)
|
|
||||||
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
|
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
|
||||||
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
|
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
|
||||||
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
|
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
|
||||||
if lang in SUPPLEMENTAL:
|
|
||||||
manifest[lang]['supplementalWords']=SUPPLEMENTAL[lang]
|
|
||||||
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
||||||
print(json.dumps(manifest,indent=2))
|
print(json.dumps(manifest,indent=2))
|
||||||
|
|||||||
@@ -40,9 +40,3 @@ for index in range(struct.unpack_from('<H', cursor, 4)[0]):
|
|||||||
# Drawing point is the tip of the pencil, not the center of its image.
|
# Drawing point is the tip of the pencil, not the center of its image.
|
||||||
struct.pack_into('<HH', cursor, entry+4, round(size*4/28), round(size*24/28))
|
struct.pack_into('<HH', cursor, entry+4, round(size*4/28), round(size*24/28))
|
||||||
(root / 'pencil.cur').write_bytes(cursor)
|
(root / 'pencil.cur').write_bytes(cursor)
|
||||||
|
|
||||||
# Original confirmation artwork, with native-resolution variants for display scaling.
|
|
||||||
question = root.parent / 'question.svg'
|
|
||||||
question_png = resvg_py.svg_to_bytes(svg_string=question.read_text(encoding='utf-8'), width=256, height=256)
|
|
||||||
Image.open(BytesIO(question_png)).convert('RGBA').save(
|
|
||||||
question.with_suffix('.ico'), sizes=[(s,s) for s in (16,20,24,32,40,48,64,96,128,256)])
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path $PSScriptRoot
|
$root = Split-Path $PSScriptRoot
|
||||||
try {
|
try {
|
||||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $root 'build\KSwitcher.Screenshot.exe') }) { throw 'Close screenshot editors before updating the development build.' }
|
|
||||||
$exe = Join-Path $root 'build\KSwitcher.exe'
|
$exe = Join-Path $root 'build\KSwitcher.exe'
|
||||||
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
|
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
|
||||||
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
|
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
|
||||||
@@ -11,7 +10,6 @@ try {
|
|||||||
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
|
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Screenshot.exe') -Destination (Join-Path $root 'build\KSwitcher.Screenshot.exe') -Force
|
|
||||||
$eventName = 'Local\KSwitcher.Update.Started.' + [guid]::NewGuid().ToString('N')
|
$eventName = 'Local\KSwitcher.Update.Started.' + [guid]::NewGuid().ToString('N')
|
||||||
$started = [System.Threading.EventWaitHandle]::new($false, [System.Threading.EventResetMode]::ManualReset, $eventName)
|
$started = [System.Threading.EventWaitHandle]::new($false, [System.Threading.EventResetMode]::ManualReset, $eventName)
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user