Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 |
@@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
+6
-2
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.17 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.22 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()
|
||||||
@@ -17,6 +17,8 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
|||||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
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)
|
||||||
|
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
||||||
|
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
||||||
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/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||||
@@ -39,7 +41,7 @@ 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 src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests 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)
|
||||||
@@ -49,12 +51,14 @@ else()
|
|||||||
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(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(KSwitcherScrollTests 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++)
|
||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
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>")
|
||||||
|
|||||||
@@ -220,17 +220,26 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||||
|
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||||
|
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||||
|
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||||
|
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
||||||
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
|
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||||
|
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||||
|
history and cloud synchronization. Paste is not blindly retried.
|
||||||
- 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.
|
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||||
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
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.
|
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
||||||
|
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
|
||||||
|
and no Enter action is sent.
|
||||||
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
`{{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.
|
||||||
@@ -273,6 +282,12 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
|||||||
|
|
||||||
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.
|
||||||
|
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||||
|
in the context menu opens the original image in the built-in screenshot editor.
|
||||||
|
Annotate it there, then copy the result. Opening the editor leaves the
|
||||||
|
clipboard and original history entry unchanged. Text items still open as text;
|
||||||
|
Enter and double-click still paste the selected item.
|
||||||
|
|
||||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||||
|
|
||||||
## Data and migration
|
## Data and migration
|
||||||
@@ -282,27 +297,38 @@ 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.
|
||||||
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
|
||||||
Only failures flush this context to `kswitcher.log`; flushed
|
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
context. Successful operations stay in that bounded memory buffer and are included only when
|
||||||
|
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; each field is limited to 512 UTF-16 code units.
|
original length and a truncation flag; context fields are 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. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
||||||
An unchanged result containing no letters, or a direct command
|
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
||||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
checks the focused editor without moving the caret, changing selection, or copying again.
|
||||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
|
||||||
|
**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
|
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.
|
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,
|
||||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
||||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -454,6 +480,13 @@ against a staged build. It briefly opens real selection/editor windows without c
|
|||||||
|
|
||||||
### Release channel rule
|
### Release channel rule
|
||||||
|
|
||||||
|
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
|
||||||
|
the latest version and relevant feature documentation in both English and Ukrainian.
|
||||||
|
Review all changes since the last documented release, update affected instructions,
|
||||||
|
shortcuts, settings and screenshots, and verify the published pages and links.
|
||||||
|
The release workflow is incomplete until this is done. Follow the mandatory
|
||||||
|
[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
|
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`,
|
`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
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||||
|
|||||||
Binary file not shown.
+9
-7
@@ -1,18 +1,20 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.17 — 2026-10-30
|
## v0.6.22 — 2026-10-31
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Hold Shift with the screenshot pencil to draw horizontal, vertical or 45-degree diagonal segments. Release Shift to continue freehand within the same stroke.
|
- Recover partial selections during recent-text conversion, repeat conversion and undo: when copying returns a verified suffix of the expected text, extend the selection with paced Shift+Left presses and copy again. Replacement still requires a complete verified match.
|
||||||
- Move more than 50 image pixels away from the active line to change direction while preserving earlier segments. Smooth diagonal gestures use the actual mouse trajectory instead of producing stair steps.
|
- Recent-text suffix checks map physical keys through convertible layouts, including Cyrillic and mixed case. This covers reported suffixes such as `Switcher ` from `PuntoSwitcher `, `зштп ` from the layout equivalent of `snipping `, and `лувШт ` from `LinkedIn `.
|
||||||
- Keep diagonal strokes inside image boundaries without changing their angle. Ctrl retains its existing draw-over-objects behavior; undo/redo treats the complete stroke as one object.
|
- Diagnostic context records the expected previous result and selection-recovery stages. Unrelated or empty copied ranges are not extended; input/context changes still cancel replacement.
|
||||||
- Disable snippet fields, Delete and marker insertion until a snippet is selected or added. Deleting the selected snippet clears and disables the editor.
|
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Passed 223 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks, updater progress checks, and 45 isolated configurator process checks. Screenshot UI coverage includes smooth diagonal gestures in all four directions, direction changes, image boundaries, Shift transitions, Ctrl behavior and undo/redo. Configurator checks cover disabled snippet editing, Add, Delete, reselection and preservation of snippet content.
|
419 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks passed, including Cyrillic and mixed-case suffix verification. The isolated editor integration test passed partial-selection recovery, the `PuntoSwitcher ` repeat-conversion case, clipboard restoration and rejection of unrelated text. Direct validation in LinkedIn and ChatGPT is still outstanding; native-editor tests do not establish browser-editor compatibility.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
Recovery is attempted once and still depends on the editor processing simulated selection keys. A failed full-text check cancels replacement. Existing clipboard, accessibility and input-context restrictions remain; this patch does not change paste readback verification.
|
||||||
|
|
||||||
**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.
|
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
d5813a42105a2aebb5b6ef414c0546a44f18e4d7f3d45e0f32830097781b9677 KSwitcher-win-x64.zip
|
b80632cc93abadf2a23921c3275b8ff37d98c92b8b583a9621d0c2b589ed6a35 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ $outDir = Join-Path $root 'build'
|
|||||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||||
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
||||||
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||||
|
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||||
|
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||||
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')
|
||||||
@@ -39,6 +41,10 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||||
|
& .\build\KSwitcherScrollTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||||
& $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
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,17,0
|
FILEVERSION 0,6,22,0
|
||||||
PRODUCTVERSION 0,6,17,0
|
PRODUCTVERSION 0,6,22,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.17"
|
VALUE "FileVersion", "0.6.22"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+117
-26
@@ -2,6 +2,8 @@
|
|||||||
#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 "screenshot_notifications.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -12,6 +14,12 @@ 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()
|
ULONG_PTR snippetReplayMarker()
|
||||||
{
|
{
|
||||||
static const char marker{};
|
static const char marker{};
|
||||||
@@ -116,7 +124,13 @@ class Transaction
|
|||||||
{
|
{
|
||||||
check();
|
check();
|
||||||
adopted.clear();
|
adopted.clear();
|
||||||
app.ownSequence = expected = writeClipboard(app.window, c);
|
// These are temporary transaction contents, not a user's clipboard entry.
|
||||||
|
// In particular, do not invite the Windows history service to read our
|
||||||
|
// sentinel while the target editor is trying to open the clipboard.
|
||||||
|
auto temporary = c;
|
||||||
|
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
|
||||||
|
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
|
||||||
|
app.ownSequence = expected = writeClipboard(app.window, temporary);
|
||||||
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
||||||
// Never adopt that writer's sequence as our own and paste its content.
|
// Never adopt that writer's sequence as our own and paste its content.
|
||||||
if (GetClipboardOwner() != app.window)
|
if (GetClipboardOwner() != app.window)
|
||||||
@@ -138,6 +152,8 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
ULONGLONG deadline = GetTickCount64() + 2000;
|
ULONGLONG deadline = GetTickCount64() + 2000;
|
||||||
|
ULONGLONG retryAt = GetTickCount64() + 250;
|
||||||
|
unsigned attempts = 1;
|
||||||
DWORD changed = before;
|
DWORD changed = before;
|
||||||
ULONGLONG stable = 0;
|
ULONGLONG stable = 0;
|
||||||
bool changedAgain = false;
|
bool changedAgain = false;
|
||||||
@@ -154,6 +170,21 @@ class Transaction
|
|||||||
}
|
}
|
||||||
if (changed != before && GetTickCount64() - stable >= 60)
|
if (changed != before && GetTickCount64() - stable >= 60)
|
||||||
break;
|
break;
|
||||||
|
// Native editors can silently drop Ctrl+C if another process has
|
||||||
|
// the clipboard open. Retry only while our sentinel is untouched;
|
||||||
|
// never overwrite a copy result or another application's content.
|
||||||
|
if (changed == before && attempts < 3 && GetTickCount64() >= retryAt &&
|
||||||
|
!GetOpenClipboardWindow())
|
||||||
|
{
|
||||||
|
check();
|
||||||
|
if (!valid() || GetClipboardSequenceNumber() != before)
|
||||||
|
return {};
|
||||||
|
if (!chord(L'C', MOD_CONTROL))
|
||||||
|
return {};
|
||||||
|
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
|
||||||
|
++attempts;
|
||||||
|
retryAt = GetTickCount64() + 250;
|
||||||
|
}
|
||||||
Sleep(10);
|
Sleep(10);
|
||||||
}
|
}
|
||||||
if (changed == before || !valid())
|
if (changed == before || !valid())
|
||||||
@@ -196,7 +227,8 @@ class Transaction
|
|||||||
return selectPrevious(n, [&] { return valid(); });
|
return selectPrevious(n, [&] { return valid(); });
|
||||||
}
|
}
|
||||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||||
const std::function<bool(const std::wstring &)> &matches)
|
const std::function<bool(const std::wstring &)> &matches,
|
||||||
|
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||||
{
|
{
|
||||||
// WebView word boundaries are only candidates; exact verification is mandatory.
|
// WebView word boundaries are only candidates; exact verification is mandatory.
|
||||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||||
@@ -216,7 +248,25 @@ class Transaction
|
|||||||
return {};
|
return {};
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
return select(count) ? copy() : std::wstring{};
|
auto text = select(count) ? copy() : std::wstring{};
|
||||||
|
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||||
|
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||||
|
if (valid() && !matches(text) && !text.empty() &&
|
||||||
|
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
|
||||||
|
caretSteps(text) < count)
|
||||||
|
{
|
||||||
|
const auto missing = count - caretSteps(text);
|
||||||
|
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
|
||||||
|
for (size_t i = 0; i < missing; ++i)
|
||||||
|
{
|
||||||
|
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||||
|
return {};
|
||||||
|
Sleep(25);
|
||||||
|
}
|
||||||
|
text = copy();
|
||||||
|
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
bool paste(const Clip &clip)
|
bool paste(const Clip &clip)
|
||||||
{
|
{
|
||||||
@@ -402,7 +452,7 @@ void App::addTray()
|
|||||||
icon.uCallbackMessage = TrayMessage;
|
icon.uCallbackMessage = TrayMessage;
|
||||||
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||||
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||||
wcscpy_s(icon.szTip, L"KSwitcher — keyboard layouts & clipboard");
|
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
||||||
Shell_NotifyIconW(NIM_ADD, &icon);
|
Shell_NotifyIconW(NIM_ADD, &icon);
|
||||||
icon.uVersion = NOTIFYICON_VERSION_4;
|
icon.uVersion = NOTIFYICON_VERSION_4;
|
||||||
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
||||||
@@ -854,7 +904,18 @@ void App::transform(int command, int snippet)
|
|||||||
if (busy.exchange(true) && !preDeferred)
|
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)
|
if (preDeferred)
|
||||||
@@ -893,7 +954,7 @@ void App::transform(int command, int snippet)
|
|||||||
} reset{busy, window, preDeferred};
|
} reset{busy, window, preDeferred};
|
||||||
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;
|
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||||
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||||
if (manual)
|
if (manual)
|
||||||
{
|
{
|
||||||
@@ -918,21 +979,34 @@ 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)
|
||||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
|
try
|
||||||
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
|
{
|
||||||
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
|
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
|
||||||
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
|
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
|
||||||
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
|
||||||
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
|
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||||
L" result=" + diagnosticText(result),
|
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
|
||||||
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
||||||
reason != L"unchanged-expected");
|
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
|
||||||
|
!(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 || snippet >= 0, 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 = [&]
|
||||||
@@ -1083,7 +1157,9 @@ void App::transform(int command, int snippet)
|
|||||||
{
|
{
|
||||||
text =
|
text =
|
||||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
|
||||||
|
[&](const std::wstring &candidate)
|
||||||
|
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
||||||
hint = recent.match(text, available, physicalLayout());
|
hint = recent.match(text, available, physicalLayout());
|
||||||
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||||
@@ -1100,6 +1176,7 @@ void App::transform(int command, int snippet)
|
|||||||
previous->version == version)
|
previous->version == version)
|
||||||
{
|
{
|
||||||
expected = previous->result;
|
expected = previous->result;
|
||||||
|
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
|
||||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||||
[&](const std::wstring &candidate)
|
[&](const std::wstring &candidate)
|
||||||
{ return newlines(candidate) == newlines(previous->result); });
|
{ return newlines(candidate) == newlines(previous->result); });
|
||||||
@@ -1147,16 +1224,13 @@ void App::transform(int command, int snippet)
|
|||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int source = detect(text, available, hint);
|
to = nextConversionLayout(text, available, cycle, hint);
|
||||||
if (source < 0)
|
|
||||||
source = hint;
|
|
||||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
|
||||||
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
|
|
||||||
}
|
}
|
||||||
result = convertTo(text, available, to, hint);
|
result = convertTo(text, available, to, hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -1167,10 +1241,27 @@ void App::transform(int command, int snippet)
|
|||||||
autoSelected = false;
|
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 (result == text &&
|
if (manual && result == text)
|
||||||
(std::none_of(text.begin(), text.end(), letter) ||
|
{
|
||||||
(command >= Direct && detect(text, available, hint) == to)))
|
reason = L"Conversion unchanged";
|
||||||
reason = L"unchanged-expected";
|
verification = L"calculated-result-equals-original";
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class App
|
|||||||
};
|
};
|
||||||
void showHistory(App &, bool visible = true);
|
void showHistory(App &, bool visible = true);
|
||||||
void launchScreenshot(App &);
|
void launchScreenshot(App &);
|
||||||
|
void launchScreenshotEditor(App &, const Clip &);
|
||||||
void refreshHistory(App &);
|
void refreshHistory(App &);
|
||||||
void showSettings(App &, bool visible = true);
|
void showSettings(App &, bool visible = true);
|
||||||
void showLogs(App &);
|
void showLogs(App &);
|
||||||
|
|||||||
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
|
|||||||
}
|
}
|
||||||
return best < 0 ? text : convert(text, layouts[best], to);
|
return best < 0 ? text : convert(text, layouts[best], to);
|
||||||
}
|
}
|
||||||
|
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||||
|
const std::vector<int> &cycle, int hint)
|
||||||
|
{
|
||||||
|
if (cycle.empty())
|
||||||
|
return -1;
|
||||||
|
int source = detect(text, layouts, hint);
|
||||||
|
if (source < 0)
|
||||||
|
source = hint;
|
||||||
|
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||||
|
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||||
|
// Compare every candidate with the original input, never an intermediate conversion.
|
||||||
|
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||||
|
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||||
|
{
|
||||||
|
int target = cycle[(start + offset) % cycle.size()];
|
||||||
|
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return cycle[start];
|
||||||
|
}
|
||||||
std::wstring cycleCase(const std::wstring &text)
|
std::wstring cycleCase(const std::wstring &text)
|
||||||
{
|
{
|
||||||
if (text == upper(text))
|
if (text == upper(text))
|
||||||
@@ -459,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
|||||||
keys.pop_back();
|
keys.pop_back();
|
||||||
at = now;
|
at = now;
|
||||||
}
|
}
|
||||||
|
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||||
|
{
|
||||||
|
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||||
|
return -1;
|
||||||
|
Run tail = *this;
|
||||||
|
tail.keys = keys.substr(keys.size() - text.size());
|
||||||
|
return tail.match(text, layouts, physical);
|
||||||
|
}
|
||||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||||
{
|
{
|
||||||
if (text.size() != keys.size() || overflow)
|
if (text.size() != keys.size() || overflow)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ std::wstring upper(std::wstring text);
|
|||||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||||
|
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||||
|
const std::vector<int> &cycle, int hint);
|
||||||
std::wstring cycleCase(const std::wstring &);
|
std::wstring cycleCase(const std::wstring &);
|
||||||
std::wstring transliterate(const std::wstring &);
|
std::wstring transliterate(const std::wstring &);
|
||||||
struct Snippet
|
struct Snippet
|
||||||
@@ -92,5 +94,6 @@ struct Run
|
|||||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||||
void backspace(HWND, ULONGLONG);
|
void backspace(HWND, ULONGLONG);
|
||||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||||
|
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||||
};
|
};
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||||
|
// visible preview to admit another visible preview: that creates a paint loop.
|
||||||
|
template<class T>
|
||||||
|
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||||
|
const std::set<std::wstring> &visible, size_t limit=32)
|
||||||
|
{
|
||||||
|
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
||||||
|
if(!visible.contains(it->first)) it=cache.erase(it);
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#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
|
||||||
+21
-2
@@ -1,6 +1,8 @@
|
|||||||
#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>
|
||||||
@@ -624,6 +626,22 @@ 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);
|
||||||
@@ -645,8 +663,9 @@ 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 << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
|
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
|
||||||
<< t.wSecond << ' ' << utf8(message) << '\n';
|
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
|
||||||
|
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
inline std::wstring localBuildTitle(const std::wstring &title)
|
||||||
|
{
|
||||||
|
static const bool development=[] {
|
||||||
|
wchar_t path[32768]{};
|
||||||
|
if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false;
|
||||||
|
std::error_code error;
|
||||||
|
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
|
||||||
|
}();
|
||||||
|
return development?title+L" [LOCAL DEV]":title;
|
||||||
|
}
|
||||||
using Bytes = std::vector<unsigned char>;
|
using Bytes = std::vector<unsigned char>;
|
||||||
struct Format
|
struct Format
|
||||||
{
|
{
|
||||||
@@ -66,6 +76,7 @@ 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
|
||||||
|
|||||||
+1
-1
@@ -841,7 +841,7 @@ void edit(HWND w, Capture &c)
|
|||||||
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
|
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++);
|
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
|
||||||
SetWindowTextW(w,title.c_str());
|
SetWindowTextW(w,localBuildTitle(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,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
void launchScreenshot(App &app)
|
static void launch(App &app, const Clip *clip)
|
||||||
{
|
{
|
||||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
auto command=L"\""+path.wstring()+L"\" ";
|
||||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip);
|
||||||
|
else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||||
if(app.settings.writeLog) command+=L" --write-log";
|
if(app.settings.writeLog) command+=L" --write-log";
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
|
|||||||
CloseHandle(process.hThread);
|
CloseHandle(process.hThread);
|
||||||
CloseHandle(process.hProcess);
|
CloseHandle(process.hProcess);
|
||||||
}
|
}
|
||||||
|
void launchScreenshot(App &app) { launch(app,nullptr); }
|
||||||
|
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &clip)
|
||||||
|
{
|
||||||
|
// Normalize PNG/DIBV5 and other supported clipboard images at full resolution.
|
||||||
|
auto pixels=decodeImage(clip);
|
||||||
|
if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER))
|
||||||
|
throw std::runtime_error("Image is too large for the screenshot editor");
|
||||||
|
auto dib=imageDib(pixels);
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||||
|
std::wstring id(value+1,36);
|
||||||
|
atomicWrite(directory/(id+L".capture"),protect(dib.data));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||||
{
|
{
|
||||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
|||||||
DWORD startScreenshotActivationListener();
|
DWORD startScreenshotActivationListener();
|
||||||
bool validScreenshotToken(const std::wstring &);
|
bool validScreenshotToken(const std::wstring &);
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-255
@@ -1,9 +1,12 @@
|
|||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "history_preview.h"
|
||||||
|
#include "ui_scroll.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#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>
|
||||||
@@ -34,7 +37,7 @@ struct WindowLayout
|
|||||||
bool scrolling{}, arranging{}, history{};
|
bool scrolling{}, arranging{}, history{};
|
||||||
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{};
|
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
||||||
bool autoOptions{};
|
bool autoOptions{};
|
||||||
};
|
};
|
||||||
WindowLayout *windowLayoutState(HWND w)
|
WindowLayout *windowLayoutState(HWND w)
|
||||||
@@ -88,6 +91,11 @@ void arrangeWindow(HWND w)
|
|||||||
next.right += extraWidth;
|
next.right += extraWidth;
|
||||||
next.bottom += extraHeight;
|
next.bottom += extraHeight;
|
||||||
}
|
}
|
||||||
|
if (child == layout->details)
|
||||||
|
{
|
||||||
|
next.right += extraWidth;
|
||||||
|
OffsetRect(&next, 0, extraHeight);
|
||||||
|
}
|
||||||
if (child == layout->bottomLeft || child == layout->bottomRight)
|
if (child == layout->bottomLeft || child == layout->bottomRight)
|
||||||
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
||||||
if (layout->autoOptions)
|
if (layout->autoOptions)
|
||||||
@@ -232,6 +240,9 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
|
|||||||
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||||
GetModuleHandleW(nullptr), nullptr);
|
GetModuleHandleW(nullptr), nullptr);
|
||||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||||
|
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
|
||||||
|
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
|
||||||
|
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
|
||||||
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
||||||
{
|
{
|
||||||
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
||||||
@@ -311,7 +322,7 @@ std::wstring value(HWND w, int id)
|
|||||||
text.resize(n);
|
text.resize(n);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
void value(HWND w, int id, const std::wstring &text)
|
[[maybe_unused]] void value(HWND w, int id, const std::wstring &text)
|
||||||
{
|
{
|
||||||
SetDlgItemTextW(w, id, text.c_str());
|
SetDlgItemTextW(w, id, text.c_str());
|
||||||
}
|
}
|
||||||
@@ -362,11 +373,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
|||||||
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
||||||
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
||||||
SetWindowSubclass(w, layoutProc, 2, 0);
|
SetWindowSubclass(w, layoutProc, 2, 0);
|
||||||
|
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
|
||||||
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
||||||
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
||||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||||
arrangeWindow(w);
|
arrangeWindow(w);
|
||||||
SetWindowTextW(w, title);
|
SetWindowTextW(w, localBuildTitle(title).c_str());
|
||||||
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
||||||
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
|
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
|
||||||
@@ -472,7 +484,17 @@ struct HistoryView
|
|||||||
std::map<std::wstring, std::string> richTexts;
|
std::map<std::wstring, std::string> richTexts;
|
||||||
HWND rich{};
|
HWND rich{};
|
||||||
std::set<std::wstring> pending;
|
std::set<std::wstring> pending;
|
||||||
|
bool scrolling{};
|
||||||
};
|
};
|
||||||
|
bool selectedImage(HWND w, HistoryView *v)
|
||||||
|
{
|
||||||
|
int i=(int)SendDlgItemMessageW(w,102,LB_GETCURSEL,0,0);
|
||||||
|
return i>=0 && i<(int)v->items.size() && v->items[i].image;
|
||||||
|
}
|
||||||
|
void updateHistoryActions(HWND w, HistoryView *v)
|
||||||
|
{
|
||||||
|
SetDlgItemTextW(w,114,selectedImage(w,v)?L"Edit image":L"Open item");
|
||||||
|
}
|
||||||
std::wstring selected(HWND w, HistoryView *v)
|
std::wstring selected(HWND w, HistoryView *v)
|
||||||
{
|
{
|
||||||
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
|
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
|
||||||
@@ -501,6 +523,7 @@ void search(HWND w, HistoryView *v)
|
|||||||
}
|
}
|
||||||
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
||||||
{
|
{
|
||||||
|
if(v->scrolling) return;
|
||||||
const auto id = entry.id;
|
const auto id = entry.id;
|
||||||
const bool image = entry.image;
|
const bool image = entry.image;
|
||||||
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
|
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
|
||||||
@@ -684,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
switch (msg)
|
switch (msg)
|
||||||
{
|
{
|
||||||
|
case ScrollActivity:
|
||||||
|
if((HWND)wp==GetDlgItem(w,102)) {
|
||||||
|
v->scrolling=lp!=0;
|
||||||
|
if(!v->scrolling) InvalidateRect((HWND)wp,nullptr,FALSE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
@@ -696,6 +725,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
|
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
{
|
{
|
||||||
|
updateHistoryActions(w,v);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||||
@@ -741,10 +771,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (result->generation != v->generation)
|
if (result->generation != v->generation)
|
||||||
return 0;
|
return 0;
|
||||||
auto old = selected(w, v);
|
auto old = selected(w, v);
|
||||||
|
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||||
|
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
||||||
v->items = std::move(result->items);
|
v->items = std::move(result->items);
|
||||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
||||||
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
||||||
int select = 0;
|
int select = 0;
|
||||||
|
int restoreTop=0;
|
||||||
for (size_t i = 0; i < v->items.size(); ++i)
|
for (size_t i = 0; i < v->items.size(); ++i)
|
||||||
{
|
{
|
||||||
auto &e = v->items[i];
|
auto &e = v->items[i];
|
||||||
@@ -752,8 +785,11 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
|
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
|
||||||
if (e.id == old)
|
if (e.id == old)
|
||||||
select = (int)i;
|
select = (int)i;
|
||||||
|
if(e.id==topId) restoreTop=(int)i;
|
||||||
}
|
}
|
||||||
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
|
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
|
||||||
|
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
|
||||||
|
updateHistoryActions(w,v);
|
||||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
|
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
|
||||||
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
|
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -764,16 +800,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (result->generation != v->generation || !IsWindowVisible(w))
|
if (result->generation != v->generation || !IsWindowVisible(w))
|
||||||
return 0;
|
return 0;
|
||||||
v->pending.erase(result->id);
|
v->pending.erase(result->id);
|
||||||
// At most ~3.3 MiB, irrespective of history length or source image size.
|
auto list=GetDlgItem(w,102);
|
||||||
if (v->thumbnails.size() >= 32)
|
RECT client{}; GetClientRect(list,&client);
|
||||||
v->thumbnails.erase(v->thumbnails.begin());
|
std::set<std::wstring> visible;
|
||||||
if (v->richTexts.size() >= 32)
|
RECT changed{}; bool repaint=false;
|
||||||
v->richTexts.erase(v->richTexts.begin());
|
int top=(int)SendMessageW(list,LB_GETTOPINDEX,0,0);
|
||||||
|
for(int i=std::max(0,top);i<(int)v->items.size();++i) {
|
||||||
|
RECT row{}, intersection{};
|
||||||
|
if(SendMessageW(list,LB_GETITEMRECT,i,(LPARAM)&row)==LB_ERR || row.top>=client.bottom) break;
|
||||||
|
if(!IntersectRect(&intersection,&row,&client)) continue;
|
||||||
|
visible.insert(v->items[i].id);
|
||||||
|
if(v->items[i].id==result->id) { changed=intersection; repaint=true; }
|
||||||
|
}
|
||||||
if (!result->rtf.empty())
|
if (!result->rtf.empty())
|
||||||
v->richTexts[result->id] = std::move(result->rtf);
|
v->richTexts[result->id] = std::move(result->rtf);
|
||||||
else
|
else
|
||||||
v->thumbnails[result->id] = std::move(result->pixels);
|
v->thumbnails[result->id] = std::move(result->pixels);
|
||||||
InvalidateRect(GetDlgItem(w, 102), nullptr, FALSE);
|
trimHistoryPreviews(v->thumbnails,visible);
|
||||||
|
trimHistoryPreviews(v->richTexts,visible);
|
||||||
|
if(repaint) InvalidateRect(list,&changed,FALSE);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_MEASUREITEM:
|
case WM_MEASUREITEM:
|
||||||
@@ -792,15 +837,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case WM_CONTEXTMENU:
|
case WM_CONTEXTMENU:
|
||||||
{
|
{
|
||||||
|
auto list=GetDlgItem(w,102);
|
||||||
|
if((HWND)wp!=list) break;
|
||||||
|
POINT p{(short)LOWORD(lp),(short)HIWORD(lp)};
|
||||||
|
if(lp!=-1) {
|
||||||
|
POINT hit=p; ScreenToClient(list,&hit);
|
||||||
|
auto item=SendMessageW(list,LB_ITEMFROMPOINT,0,MAKELPARAM(hit.x,hit.y));
|
||||||
|
if(HIWORD(item)) return 0;
|
||||||
|
SendMessageW(list,LB_SETCURSEL,LOWORD(item),0);
|
||||||
|
updateHistoryActions(w,v);
|
||||||
|
} else {
|
||||||
|
RECT row{};
|
||||||
|
auto index=SendMessageW(list,LB_GETCURSEL,0,0);
|
||||||
|
if(index==LB_ERR) return 0;
|
||||||
|
SendMessageW(list,LB_GETITEMRECT,index,(LPARAM)&row);
|
||||||
|
p={row.left,row.top}; ClientToScreen(list,&p);
|
||||||
|
}
|
||||||
HMENU menu = CreatePopupMenu();
|
HMENU menu = CreatePopupMenu();
|
||||||
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
||||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
||||||
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, selectedImage(w,v)?L"Edit image in screenshot editor":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 (OCR)");
|
||||||
POINT 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);
|
||||||
DestroyMenu(menu);
|
DestroyMenu(menu);
|
||||||
if (cmd)
|
if (cmd)
|
||||||
@@ -1573,248 +1632,7 @@ void showHistory(App &app, bool visible)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifndef KS_RESIDENT_APP
|
#ifndef KS_RESIDENT_APP
|
||||||
namespace
|
#include "ui_log.inl"
|
||||||
{
|
|
||||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
|
||||||
LogClear = 606;
|
|
||||||
void fitLogDetails(HWND list)
|
|
||||||
{
|
|
||||||
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
|
|
||||||
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
|
|
||||||
RECT client{};
|
|
||||||
GetClientRect(list, &client);
|
|
||||||
int remaining = client.right;
|
|
||||||
for (int column = 0; column < 3; ++column)
|
|
||||||
remaining -= ListView_GetColumnWidth(list, column);
|
|
||||||
int width = std::max(scale(list, 120), remaining);
|
|
||||||
if (ListView_GetColumnWidth(list, 3) != width)
|
|
||||||
ListView_SetColumnWidth(list, 3, width);
|
|
||||||
RemovePropW(list, L"FittingLogDetails");
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
|
||||||
{
|
|
||||||
auto result = DefSubclassProc(w, msg, wp, lp);
|
|
||||||
if (msg == WM_SIZE)
|
|
||||||
fitLogDetails(w);
|
|
||||||
if (msg == WM_NOTIFY && lp)
|
|
||||||
{
|
|
||||||
auto *header = reinterpret_cast<NMHEADERW *>(lp);
|
|
||||||
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
|
|
||||||
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
|
|
||||||
header->iItem >= 0 && header->iItem < 3)
|
|
||||||
fitLogDetails(w);
|
|
||||||
}
|
|
||||||
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
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())
|
|
||||||
{
|
|
||||||
auto clip = textClip(copied);
|
|
||||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
|
||||||
app.ownSequence = writeClipboard(app.window, clip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
fitLogDetails(list);
|
|
||||||
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
|
|
||||||
{
|
|
||||||
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 && ((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, logListResizeProc, 3, 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);
|
|
||||||
}
|
|
||||||
auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.",
|
|
||||||
0, 0, 16, 551, 720, 22);
|
|
||||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
|
||||||
auto *layout = windowLayoutState(w);
|
|
||||||
layout->stretch = list;
|
|
||||||
layout->bottomLeft = hint;
|
|
||||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
|
||||||
arrangeWindow(w);
|
|
||||||
refreshLogView(w, app);
|
|
||||||
showRequestedWindow(w);
|
|
||||||
activateWindow(w);
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||||
#endif
|
#endif
|
||||||
@@ -1935,11 +1753,17 @@ void showEntry(App &app, const std::wstring &id)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto clip = app.history->load(id);
|
auto clip = app.history->load(id);
|
||||||
|
if(std::any_of(clip.formats.begin(),clip.formats.end(),[](const auto &format) {
|
||||||
|
return format.id==CF_DIB || format.id==CF_DIBV5 || format.name==L"PNG";
|
||||||
|
})) {
|
||||||
|
launchScreenshotEditor(app,clip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
auto text = clip.text();
|
auto text = clip.text();
|
||||||
if (text.empty())
|
if (text.empty())
|
||||||
{
|
{
|
||||||
MessageBoxW(app.historyWindow,
|
MessageBoxW(app.historyWindow,
|
||||||
L"This item contains an image or files. Use the preview or paste it into the "
|
L"This item contains files. Paste it into the "
|
||||||
L"destination application.",
|
L"destination application.",
|
||||||
L"Clipboard item", MB_OK);
|
L"Clipboard item", MB_OK);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <commctrl.h>
|
||||||
|
#include <windowsx.h>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
inline constexpr UINT ScrollActivity=WM_APP+42;
|
||||||
|
inline constexpr UINT_PTR ScrollFrameTimer=0x4b5301, ScrollIdleTimer=0x4b5302;
|
||||||
|
struct WheelBatch {
|
||||||
|
long long vertical{}, horizontal{};
|
||||||
|
WORD keys{};
|
||||||
|
LPARAM point{};
|
||||||
|
bool frame{}, active{}, listBox{};
|
||||||
|
int remainder{};
|
||||||
|
};
|
||||||
|
inline LRESULT CALLBACK wheelBatchProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
|
||||||
|
{
|
||||||
|
auto *batch=reinterpret_cast<WheelBatch *>(data);
|
||||||
|
if(msg==WM_MOUSEWHEEL || msg==WM_MOUSEHWHEEL) {
|
||||||
|
(msg==WM_MOUSEWHEEL?batch->vertical:batch->horizontal)+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||||
|
batch->keys=GET_KEYSTATE_WPARAM(wp); batch->point=lp;
|
||||||
|
if(!batch->active) {
|
||||||
|
batch->active=true;
|
||||||
|
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,TRUE);
|
||||||
|
}
|
||||||
|
// Do not restart the frame timer: an uninterrupted wheel stream must
|
||||||
|
// keep moving, rather than wait for a debounce interval to expire.
|
||||||
|
if(!batch->frame) batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||||
|
SetTimer(w,ScrollIdleTimer,100,nullptr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_TIMER && wp==ScrollFrameTimer) {
|
||||||
|
KillTimer(w,ScrollFrameTimer); batch->frame=false;
|
||||||
|
if(batch->listBox && batch->vertical) {
|
||||||
|
UINT lines=3; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||||
|
if(lines==WHEEL_PAGESCROLL) {
|
||||||
|
RECT client{}; GetClientRect(w,&client);
|
||||||
|
auto height=SendMessageW(w,LB_GETITEMHEIGHT,0,0);
|
||||||
|
lines=std::max<LRESULT>(1,client.bottom/std::max<LRESULT>(1,height));
|
||||||
|
}
|
||||||
|
auto delta=batch->vertical+batch->remainder;
|
||||||
|
batch->remainder=(int)(delta%WHEEL_DELTA);
|
||||||
|
auto top=SendMessageW(w,LB_GETTOPINDEX,0,0);
|
||||||
|
auto count=SendMessageW(w,LB_GETCOUNT,0,0);
|
||||||
|
auto next=std::clamp<long long>(top-(delta/WHEEL_DELTA)*lines,0,std::max<LRESULT>(0,count-1));
|
||||||
|
if(next!=top) SendMessageW(w,LB_SETTOPINDEX,(WPARAM)next,0);
|
||||||
|
batch->vertical=0;
|
||||||
|
} else if(batch->vertical) {
|
||||||
|
// Native edit/list-view/window handlers retain their own scrolling
|
||||||
|
// semantics, including system settings and partial wheel deltas.
|
||||||
|
auto delta=(short)std::clamp<long long>(batch->vertical,-32760,32760);
|
||||||
|
batch->vertical-=delta;
|
||||||
|
DefSubclassProc(w,WM_MOUSEWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||||
|
}
|
||||||
|
if(batch->horizontal) {
|
||||||
|
auto delta=(short)std::clamp<long long>(batch->horizontal,-32760,32760);
|
||||||
|
batch->horizontal-=delta;
|
||||||
|
DefSubclassProc(w,WM_MOUSEHWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||||
|
}
|
||||||
|
if(batch->vertical || batch->horizontal)
|
||||||
|
batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_TIMER && wp==ScrollIdleTimer) {
|
||||||
|
if(batch->frame) return 0;
|
||||||
|
KillTimer(w,ScrollIdleTimer); batch->active=false;
|
||||||
|
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,FALSE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_NCDESTROY) {
|
||||||
|
KillTimer(w,ScrollFrameTimer); KillTimer(w,ScrollIdleTimer);
|
||||||
|
RemoveWindowSubclass(w,wheelBatchProc,id); delete batch;
|
||||||
|
}
|
||||||
|
return DefSubclassProc(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
inline void enableWheelBatching(HWND window,bool listBox=false)
|
||||||
|
{
|
||||||
|
if(!window) return;
|
||||||
|
auto *batch=new WheelBatch; batch->listBox=listBox;
|
||||||
|
if(!SetWindowSubclass(window,wheelBatchProc,ScrollFrameTimer,(DWORD_PTR)batch)) delete batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.17";
|
inline constexpr wchar_t AppVersion[] = L"0.6.22";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
@@ -39,7 +39,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 log'}}
|
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
|
||||||
$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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "ui_scroll.h"
|
||||||
#include "ui_geometry.h"
|
#include "ui_geometry.h"
|
||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -728,16 +729,21 @@ int main(int argc, char **argv) {
|
|||||||
controlMoves=0;
|
controlMoves=0;
|
||||||
ValidateRect(settings,nullptr);
|
ValidateRect(settings,nullptr);
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
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);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||||
|
require(controlMoves==0,"wheel changes wait for the shared frame timer");
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
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;
|
controlMoves=0;
|
||||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
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");
|
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||||
|
|||||||
+233
-7
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
@@ -21,9 +22,66 @@ 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;
|
int enterPresses = 0;
|
||||||
|
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||||
|
int droppedSelectionKeys = 0;
|
||||||
|
bool temporaryClipboardExcluded = true;
|
||||||
|
bool clipboardAccessTimedOut = false;
|
||||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
{
|
{
|
||||||
|
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||||||
|
(GetKeyState(VK_SHIFT) & 0x8000))
|
||||||
|
{
|
||||||
|
--droppedSelectionKeys;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// This bare EDIT control is our test fixture, not a full editor with
|
||||||
|
// clipboard retries. Keep clipboard access on its owning UI thread while
|
||||||
|
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
||||||
|
// make the fixture silently discard the operation under test.
|
||||||
|
struct ClipboardAccess
|
||||||
|
{
|
||||||
|
bool opened = false;
|
||||||
|
~ClipboardAccess() { if (opened) CloseClipboard(); }
|
||||||
|
} access;
|
||||||
|
if (message == WM_CHAR && (wp == 3 || wp == 22))
|
||||||
|
{
|
||||||
|
auto deadline = GetTickCount64() + 1000;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if (OpenClipboard(w)) { access.opened = true; break; }
|
||||||
|
Sleep(10);
|
||||||
|
} while (GetTickCount64() < deadline);
|
||||||
|
if (!access.opened)
|
||||||
|
{
|
||||||
|
clipboardAccessTimedOut = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
|
||||||
|
{
|
||||||
|
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
|
||||||
|
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
|
||||||
|
if (message == WM_CHAR)
|
||||||
|
{
|
||||||
|
++copyRequests;
|
||||||
|
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
|
||||||
|
// Reproduce that outcome deterministically, without depending on
|
||||||
|
// the timing of the Windows clipboard-history service.
|
||||||
|
if (copyFailuresRemaining > 0)
|
||||||
|
{
|
||||||
|
--copyFailuresRemaining;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||||||
return DefSubclassProc(w, message, wp, lp);
|
return DefSubclassProc(w, message, wp, lp);
|
||||||
}
|
}
|
||||||
@@ -43,13 +101,15 @@ std::string diagnosticLog()
|
|||||||
size_t diagnosticStart{};
|
size_t diagnosticStart{};
|
||||||
void require(bool ok, const char *name)
|
void require(bool ok, const char *name)
|
||||||
{
|
{
|
||||||
if (!ok)
|
if (!ok || clipboardAccessTimedOut)
|
||||||
{
|
{
|
||||||
wchar_t observed[2048]{};
|
wchar_t observed[2048]{};
|
||||||
GetWindowTextW(field, observed, 2048);
|
GetWindowTextW(field, observed, 2048);
|
||||||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||||||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||||||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
||||||
|
if (clipboardAccessTimedOut)
|
||||||
|
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
|
||||||
throw std::runtime_error(name);
|
throw std::runtime_error(name);
|
||||||
}
|
}
|
||||||
std::cout << "PASS " << name << '\n';
|
std::cout << "PASS " << name << '\n';
|
||||||
@@ -101,6 +161,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
if (msg == WM_TIMER)
|
if (msg == WM_TIMER)
|
||||||
{
|
{
|
||||||
|
// Input tests must not start a network update check. Its close veto can
|
||||||
|
// otherwise leave a completed test running after the editor is gone.
|
||||||
|
if (app->window) KillTimer(app->window, 5);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (GetTickCount64() > deadline)
|
if (GetTickCount64() > deadline)
|
||||||
@@ -149,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
require(text() == L"Hello World", "case conversion");
|
require(text() == L"Hello World", "case conversion");
|
||||||
|
droppedSelectionKeys = 5;
|
||||||
app->transform(ks::Case);
|
app->transform(ks::Case);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||||
|
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||||||
prepare(L"OLDghbdtn", false);
|
prepare(L"OLDghbdtn", false);
|
||||||
record(L"ghbdtn");
|
record(L"ghbdtn");
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -178,12 +243,20 @@ 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("reason=recent-verification-mismatch") != std::string::npos,
|
require(diagnostics.find("\"outcome\":\"recent-verification-mismatch\"") != std::string::npos,
|
||||||
"failed recent-word verification has explicit outcome");
|
"failed recent-word verification has explicit outcome");
|
||||||
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
|
require(diagnostics.find("\"original\":\"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");
|
||||||
@@ -241,6 +314,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
DestroyWindow(field);
|
DestroyWindow(field);
|
||||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
||||||
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
prepare(L"ghbdtn ", false);
|
prepare(L"ghbdtn ", false);
|
||||||
record(L"ghbdtn ");
|
record(L"ghbdtn ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
@@ -252,6 +326,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
DestroyWindow(field);
|
DestroyWindow(field);
|
||||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
||||||
nullptr, GetModuleHandleW(nullptr), nullptr);
|
nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
prepare(L"(ljvf ", false);
|
prepare(L"(ljvf ", false);
|
||||||
record(L"(ljvf ");
|
record(L"(ljvf ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
@@ -289,9 +364,86 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||||
stage = 20;
|
stage = 20;
|
||||||
break;
|
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);
|
||||||
|
prepare(L"PuntoSwitcher ", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 44:
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 45:
|
||||||
|
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||||
|
droppedSelectionKeys = 5;
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 46:
|
||||||
|
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||||
|
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||||
|
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel,
|
||||||
|
"suffix recovery restores original clipboard");
|
||||||
|
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||||
|
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||||
|
record(L"snipping ");
|
||||||
|
droppedSelectionKeys = 4;
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 47:
|
||||||
|
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||||
|
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
case 32:
|
case 32:
|
||||||
case 34:
|
case 34:
|
||||||
|
case 37:
|
||||||
|
// Earlier password/word tests replace the original multiline
|
||||||
|
// editor with a single-line EDIT. Restore the snippet fixture:
|
||||||
|
// a single-line control cannot insert the tested Enter action.
|
||||||
|
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
|
||||||
|
{
|
||||||
|
DestroyWindow(field);
|
||||||
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
|
||||||
|
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
|
||||||
|
GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
|
}
|
||||||
if (!captured)
|
if (!captured)
|
||||||
{
|
{
|
||||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
@@ -300,9 +452,16 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
app->settings.autoCorrect = stage == 35;
|
app->settings.autoCorrect = stage == 35;
|
||||||
app->settings.autoLanguages = {L"en", L"ru"};
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||||
|
app->settings.switchLayout = false;
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
app->autoPredictor = ks::AutoCorrector{};
|
||||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||||||
enterPresses = 0;
|
enterPresses = 0;
|
||||||
|
copyRequests = 0;
|
||||||
|
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
|
||||||
|
temporaryClipboardExcluded = true;
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
prepare(L"prefix ", false);
|
prepare(L"prefix ", false);
|
||||||
{
|
{
|
||||||
@@ -329,6 +488,10 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 33:
|
case 33:
|
||||||
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
||||||
|
require(copyRequests == 2 && copyFailuresRemaining == 0,
|
||||||
|
"snippet retries a silently dropped copy without duplicating input");
|
||||||
|
require(temporaryClipboardExcluded,
|
||||||
|
"temporary snippet clipboard contents are excluded from Windows history");
|
||||||
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||||||
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
||||||
stage = 34;
|
stage = 34;
|
||||||
@@ -352,6 +515,15 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 36:
|
case 36:
|
||||||
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
||||||
|
stage = 37;
|
||||||
|
break;
|
||||||
|
case 38:
|
||||||
|
require(copyRequests == 3, "copy retries are bounded to three attempts");
|
||||||
|
require(text() == L"prefix ;r x" && enterPresses == 0,
|
||||||
|
"exhausted copy retries preserve trigger and following input without Enter");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel,
|
||||||
|
"exhausted copy retries restore the clipboard");
|
||||||
|
copyFailuresRemaining = 0;
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
case 20:
|
case 20:
|
||||||
@@ -448,6 +620,7 @@ 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()));
|
||||||
@@ -463,9 +636,22 @@ 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)) == 4, "log viewer has four columns");
|
require(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable 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);
|
||||||
@@ -475,10 +661,11 @@ 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) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
|
require(IsWindow(viewer), "refresh keeps log viewer open");
|
||||||
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) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
|
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
|
||||||
|
"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",
|
||||||
@@ -503,7 +690,46 @@ 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
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ int main()
|
|||||||
}
|
}
|
||||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
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)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
|
||||||
RECT client{}; GetClientRect(window,&client);
|
RECT client{}; GetClientRect(window,&client);
|
||||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||||
ks::updateStatus(c);
|
ks::updateStatus(c);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#include "ui_scroll.h"
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int forwarded{};
|
||||||
|
long long totalDelta{};
|
||||||
|
LRESULT CALLBACK observe(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||||
|
{
|
||||||
|
if(msg==WM_MOUSEWHEEL) { ++forwarded; totalDelta+=GET_WHEEL_DELTA_WPARAM(wp); }
|
||||||
|
return DefSubclassProc(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(message); }
|
||||||
|
void wheel(HWND w,int delta) { SendMessageW(w,WM_MOUSEWHEEL,MAKEWPARAM(0,(WORD)(short)delta),0); }
|
||||||
|
void flush(HWND w) { SendMessageW(w,WM_TIMER,ks::ScrollFrameTimer,0); }
|
||||||
|
}
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
INITCOMMONCONTROLSEX init{sizeof(init),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||||
|
InitCommonControlsEx(&init);
|
||||||
|
auto instance=GetModuleHandleW(nullptr);
|
||||||
|
auto parent=CreateWindowW(L"STATIC",L"Scroll regression",WS_OVERLAPPEDWINDOW,0,0,500,400,nullptr,nullptr,instance,nullptr);
|
||||||
|
try {
|
||||||
|
auto list=CreateWindowW(L"LISTBOX",L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||||
|
for(int i=0;i<2000;++i) SendMessageW(list,LB_ADDSTRING,0,(LPARAM)L"row");
|
||||||
|
SendMessageW(list,LB_SETCURSEL,5,0);
|
||||||
|
SendMessageW(list,LB_SETTOPINDEX,0,0);
|
||||||
|
ks::enableWheelBatching(list,true);
|
||||||
|
for(int i=0;i<1000;++i) wheel(list,-120);
|
||||||
|
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"burst is deferred to one frame");
|
||||||
|
flush(list);
|
||||||
|
UINT lines{}; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||||
|
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"burst moves list");
|
||||||
|
require(SendMessageW(list,LB_GETCURSEL,0,0)==5,"scroll does not change selection");
|
||||||
|
for(int i=0;i<1000;++i) wheel(list,120);
|
||||||
|
flush(list);
|
||||||
|
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"reverse burst clamps at top");
|
||||||
|
wheel(list,-60); flush(list);
|
||||||
|
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"partial wheel step is retained");
|
||||||
|
wheel(list,-60); flush(list);
|
||||||
|
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"partial steps combine across frames");
|
||||||
|
DestroyWindow(list);
|
||||||
|
for(auto cls:{L"EDIT",WC_LISTVIEWW,L"COMBOBOX",L"STATIC"}) {
|
||||||
|
auto w=CreateWindowW(cls,L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||||
|
require(w!=nullptr,"native scroll control created");
|
||||||
|
SetWindowSubclass(w,observe,1,0); ks::enableWheelBatching(w);
|
||||||
|
forwarded=0; totalDelta=0;
|
||||||
|
for(int i=0;i<100;++i) wheel(w,-120);
|
||||||
|
require(forwarded==0,"native wheel burst is batched");
|
||||||
|
flush(w);
|
||||||
|
require(forwarded==1 && totalDelta==-12000,"one native dispatch preserves burst distance");
|
||||||
|
wheel(w,60); flush(w);
|
||||||
|
require(forwarded==2 && totalDelta==-11940,"native partial steps are preserved");
|
||||||
|
forwarded=0; totalDelta=0;
|
||||||
|
for(int i=0;i<400;++i) wheel(w,-120);
|
||||||
|
flush(w); flush(w);
|
||||||
|
require(forwarded==2 && totalDelta==-48000,"large native burst is not truncated to 16 bits");
|
||||||
|
SendMessageW(w,WM_TIMER,ks::ScrollIdleTimer,0);
|
||||||
|
DestroyWindow(w);
|
||||||
|
}
|
||||||
|
DestroyWindow(parent);
|
||||||
|
std::cout<<"Passed shared wheel batching regressions (list, edit, table, combo, window)\n";
|
||||||
|
return 0;
|
||||||
|
} catch(const std::exception &e) {
|
||||||
|
DestroyWindow(parent); std::cerr<<e.what()<<'\n'; return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
|
#include "history_preview.h"
|
||||||
#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>
|
||||||
@@ -21,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
|
|||||||
bool thrown = false;
|
bool thrown = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
{
|
||||||
|
std::map<std::wstring,int> previews;
|
||||||
|
std::set<std::wstring> visible;
|
||||||
|
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
||||||
|
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"preview cache stays bounded");
|
||||||
|
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
||||||
|
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
||||||
|
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
||||||
|
}
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
@@ -44,6 +60,34 @@ 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") ==
|
||||||
@@ -148,6 +192,26 @@ int main(int argc, char **argv)
|
|||||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||||
|
ks::Run suffixRun;
|
||||||
|
suffixRun.keys = L"snipping ";
|
||||||
|
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
|
||||||
|
"recent Cyrillic suffix matches physical keys across layouts");
|
||||||
|
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
|
||||||
|
"recent English suffix matches physical keys");
|
||||||
|
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
|
||||||
|
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
|
||||||
|
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
|
||||||
|
"suffix recovery rejects unrelated, empty and complete ranges");
|
||||||
|
suffixRun.overflow = true;
|
||||||
|
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
|
||||||
|
"suffix recovery rejects overflowed tracking");
|
||||||
|
suffixRun.overflow = false;
|
||||||
|
suffixRun.keys = L"LinkedIn ";
|
||||||
|
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
|
||||||
|
"ChatGPT partial CamelCase selection matches tracked physical suffix");
|
||||||
|
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
|
||||||
|
suffixRun.match(L"лувШт ", layouts, en) < 0,
|
||||||
|
"CamelCase conversion still requires the complete tracked text after recovery");
|
||||||
ks::AutoCorrector corrector;
|
ks::AutoCorrector corrector;
|
||||||
ks::Settings autoSettings;
|
ks::Settings autoSettings;
|
||||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||||
@@ -293,6 +357,22 @@ int main(int argc, char **argv)
|
|||||||
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");
|
||||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
|
||||||
|
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
|
||||||
|
"cycle skips unchanged Russian to Ukrainian and wraps to English");
|
||||||
|
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
|
||||||
|
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
|
||||||
|
"cycle skips unchanged Ukrainian to Russian");
|
||||||
|
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
|
||||||
|
"cycle keeps the next language when text changes");
|
||||||
|
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
|
||||||
|
"cycle preserves configured order for changed text");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
|
||||||
|
"cycle does not use disabled English when all candidates are unchanged");
|
||||||
|
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
|
||||||
|
"unchanged digits finish after a bounded cycle");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
|
||||||
|
"source outside cycle still skips unchanged candidates");
|
||||||
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
||||||
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
||||||
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
||||||
|
|||||||
Reference in New Issue
Block a user