Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 | |||
| c326db7dc5 |
@@ -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.
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.10 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.18 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()
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
|||||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||||
Double-click the tray icon to open Settings, the bold default menu item.
|
Double-click the tray icon to open Settings, the bold default menu item.
|
||||||
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
||||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||||
@@ -160,7 +160,11 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin
|
|||||||
for shapes.
|
for shapes.
|
||||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||||
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
||||||
|
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||||
|
While holding Shift, moving more than 50 image pixels away from the current line selects a new
|
||||||
|
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
|
||||||
|
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||||
@@ -184,7 +188,15 @@ Click the notification or **Review update** to check the latest release and conf
|
|||||||
Failed notification submissions are logged and retried after five minutes instead of being marked
|
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||||
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||||
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||||
Confirm the offered version to download and install it. The updater uses the existing
|
Confirm the offered version to download and install it. A progress window shows
|
||||||
|
download percentage and received/total size, package verification,
|
||||||
|
installation and restart. After the new process confirms startup, that window shows the installed
|
||||||
|
version and a Done button; it does not send a separate completion notification. If installation
|
||||||
|
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
||||||
|
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
||||||
|
the currently installed build, so it becomes available for subsequent updates after installing it.
|
||||||
|
|
||||||
|
The updater uses the existing
|
||||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||||
@@ -216,7 +228,10 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||||
|
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
||||||
|
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
||||||
|
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||||
full-text search, pinning, deletion, full text viewing and image previews.
|
full-text search, pinning, deletion, full text viewing and image previews.
|
||||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||||
@@ -267,25 +282,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
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -355,12 +383,15 @@ Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. En
|
|||||||
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||||
files before replacing dictionaries with a new distribution.
|
files before replacing dictionaries with a new distribution.
|
||||||
|
|
||||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
|
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||||
pending work. During the replacement itself, key presses and releases are queued and replayed after
|
key presses and releases; no typing pause is required. Queued input is replayed after
|
||||||
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
||||||
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
||||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
||||||
|
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
||||||
|
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
||||||
|
Different valid spellings remain ambiguous and are left unchanged.
|
||||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||||
does not count toward this minimum; the maximum word length is 32 letters.
|
does not count toward this minimum; the maximum word length is 32 letters.
|
||||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||||
@@ -434,6 +465,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.
+11
-7
@@ -1,24 +1,28 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.10 — 2026-10-29
|
## v0.6.18 — 2026-10-31
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Preserve typing during automatic layout correction: queue key presses and releases until replacement, clipboard restoration and layout switching finish. Preserve modifiers and Backspace; collapse temporary selection before replay when a correction is rejected.
|
- Verify the actual editor text after manual conversion using read-only Windows Accessibility, without moving the caret, changing selection or copying again. Record unchanged calculations, original text remaining, unexpected readback and unavailable verification separately.
|
||||||
- Add My dictionary fields for English, Ukrainian and Russian in Auto-correction options. Enter correct words separated by commas or new lines; save them with settings without editing dictionary files, sorting entries or assigning ranks.
|
- Replace the event-only diagnostic list with sortable Time, Program, Command, Source layout, Target layout and Result columns; add program/command/result filters and a separate panel with original, expected and read-back text and operation context.
|
||||||
- Accept unsorted dictionary files and duplicate entries, retaining the lowest rank for duplicates. Include Ukrainian `реліз` in the bundled vocabulary so `релыз` can be corrected when Russian and Ukrainian are enabled.
|
- Keep successful operations only in a bounded in-memory context (40 events, 30 seconds). Only problem operations create file rows; verified successes do not fill the log. Diagnostics work in normal Release builds and the additional readback check is skipped when logging is disabled.
|
||||||
|
- Refresh English and Ukrainian Wiki documentation through v0.6.18, including previously undocumented snippet, updater and constrained-pencil changes since v0.6.14.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
Some editors do not expose their text through Windows Accessibility. Changed focus, protected or unsupported controls, provider-specific character units, delayed text updates and the 4096-character readback limit can prevent verification; these cases are recorded as unverified rather than successful. Historical unstructured entries remain readable without retroactive filter fields.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Native regression checks cover personal words, settings persistence, language validation, dictionary ordering and duplicate ranks. Input/UI checks cover personal-word entry and normalization. Native editor integration covers typing with Shift and Backspace during successful and rejected corrections. Release checks also cover screenshot UI, isolated notification registration and updater validation.
|
Passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks, updater progress checks, 45 isolated configurator process checks, conversion-specific editor integration and diagnostic viewer checks. Verified that 1000 successful operations create no log file and that a subsequent failure creates one row with bounded context. The broader editor integration suite still intermittently fails in snippet Enter/concurrent-input scenarios; similar failures reproduce with the pre-change code.
|
||||||
|
|
||||||
**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. Existing customized dictionary files may be preserved by the updater; use My dictionary to add missing words without replacing those files.
|
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains 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.
|
||||||
|
|
||||||
Build and package:
|
Build and package:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\build.ps1 -Stage -Test -Integration
|
.\build.ps1 -Stage -Test
|
||||||
.\package.ps1 -PublishRelease
|
.\package.ps1 -PublishRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
36966eb0b2b0af47c245aec6337a16703e0fbdca4d3eda7a8f25fa14a20f347e KSwitcher-win-x64.zip
|
3e76d8a566c623a3084c00cc909f4cc7c8a6edb38f9c06adbb32edfd4cfcb5b7 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ try {
|
|||||||
|
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||||
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||||
|
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||||
}
|
}
|
||||||
if ($Integration) {
|
if ($Integration) {
|
||||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||||
|
|||||||
+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,10,0
|
FILEVERSION 0,6,18,0
|
||||||
PRODUCTVERSION 0,6,10,0
|
PRODUCTVERSION 0,6,18,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.10"
|
VALUE "FileVersion", "0.6.18"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+213
-3
@@ -3,10 +3,192 @@
|
|||||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||||
|
[switch]$ShowProgress,
|
||||||
[switch]$Library
|
[switch]$Library
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
$script:progressWindow = $null
|
||||||
|
|
||||||
|
function Start-UpdateProgress {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
if (!('KSwitcherUpdateProgress' -as [type])) {
|
||||||
|
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public sealed class UpdateMeter : Control {
|
||||||
|
public int Percent = -1;
|
||||||
|
private int position;
|
||||||
|
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
||||||
|
public UpdateMeter() {
|
||||||
|
DoubleBuffered = true;
|
||||||
|
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
||||||
|
timer.Start();
|
||||||
|
}
|
||||||
|
protected override void OnPaint(PaintEventArgs e) {
|
||||||
|
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
||||||
|
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
||||||
|
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
||||||
|
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
||||||
|
}
|
||||||
|
public sealed class UpdateSteps : Control {
|
||||||
|
public int Current = -1;
|
||||||
|
public bool Complete;
|
||||||
|
public UpdateSteps() { DoubleBuffered = true; }
|
||||||
|
protected override void OnPaint(PaintEventArgs e) {
|
||||||
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
string[] names = {"Download","Verify","Install","Restart"};
|
||||||
|
for(int i=0;i<4;i++) {
|
||||||
|
int x = i*(Width-80)/3;
|
||||||
|
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
||||||
|
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
||||||
|
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
||||||
|
using(var brush = new SolidBrush(color)) {
|
||||||
|
if(Complete || i<Current) {
|
||||||
|
e.Graphics.FillEllipse(brush,x,0,16,16);
|
||||||
|
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
||||||
|
} else {
|
||||||
|
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
||||||
|
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
||||||
|
}
|
||||||
|
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public sealed class KSwitcherUpdateProgress : IDisposable {
|
||||||
|
private Form form;
|
||||||
|
private Label stage, detail, percentage, footer;
|
||||||
|
private UpdateMeter progress;
|
||||||
|
private UpdateSteps steps;
|
||||||
|
private Button done, close;
|
||||||
|
private bool closing;
|
||||||
|
private Exception failure;
|
||||||
|
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
||||||
|
private readonly Thread thread;
|
||||||
|
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
||||||
|
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
||||||
|
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
||||||
|
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
||||||
|
public KSwitcherUpdateProgress() {
|
||||||
|
thread = new Thread(delegate() {
|
||||||
|
try {
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
||||||
|
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
||||||
|
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
||||||
|
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
||||||
|
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
||||||
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
||||||
|
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
||||||
|
};
|
||||||
|
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
||||||
|
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||||
|
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
||||||
|
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
||||||
|
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
||||||
|
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
||||||
|
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
||||||
|
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
||||||
|
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
||||||
|
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
||||||
|
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
||||||
|
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
||||||
|
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||||
|
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
||||||
|
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
||||||
|
bottom.Controls.AddRange(new Control[]{footer,done});
|
||||||
|
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
||||||
|
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
||||||
|
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
||||||
|
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
||||||
|
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
||||||
|
Application.Run(form);
|
||||||
|
} catch(Exception e) { failure = e; ready.Set(); }
|
||||||
|
});
|
||||||
|
thread.IsBackground = true;
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
||||||
|
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
||||||
|
}
|
||||||
|
public void Report(string title, string text, int percent) {
|
||||||
|
if (form == null || form.IsDisposed) return;
|
||||||
|
form.BeginInvoke((Action)delegate {
|
||||||
|
stage.Text = title; detail.Text = text;
|
||||||
|
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
||||||
|
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
||||||
|
int separator = text.IndexOf("% - ");
|
||||||
|
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
||||||
|
if(title.StartsWith("Downloading")) steps.Current=0;
|
||||||
|
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
||||||
|
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
||||||
|
else if(title.StartsWith("Restarting")) steps.Current=3;
|
||||||
|
progress.Invalidate(); steps.Invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public void SavePreview(string path) {
|
||||||
|
form.Invoke((Action)delegate {
|
||||||
|
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
||||||
|
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
||||||
|
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
||||||
|
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
||||||
|
public void Finish(string title, string text, bool success) {
|
||||||
|
form.Invoke((Action)delegate {
|
||||||
|
closing = true;
|
||||||
|
stage.Text = title; detail.Text = text;
|
||||||
|
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
||||||
|
steps.Complete = success;
|
||||||
|
if(!success) {
|
||||||
|
detail.Visible = false; steps.Visible = false;
|
||||||
|
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
||||||
|
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||||
|
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
||||||
|
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
||||||
|
}
|
||||||
|
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
||||||
|
close.Enabled = true; done.Visible = true;
|
||||||
|
form.AcceptButton = done; form.CancelButton = done;
|
||||||
|
progress.Invalidate(); steps.Invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public void WaitForClose() { thread.Join(); }
|
||||||
|
public void Dispose() {
|
||||||
|
if (form != null && !form.IsDisposed)
|
||||||
|
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
||||||
|
thread.Join(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
||||||
|
}
|
||||||
|
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
||||||
|
catch { Write-Warning 'Could not refresh update progress.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
||||||
|
catch { Write-Warning 'Could not display the update result.' }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Get-StableVersion([string]$Tag) {
|
function Get-StableVersion([string]$Tag) {
|
||||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
|||||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$request = [Net.HttpWebRequest]::Create($Url)
|
$request = [Net.HttpWebRequest]::Create($Url)
|
||||||
$request.AllowAutoRedirect = $false
|
$request.AllowAutoRedirect = $false
|
||||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
|||||||
$buffer = New-Object byte[] 65536
|
$buffer = New-Object byte[] 65536
|
||||||
$total = 0L
|
$total = 0L
|
||||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||||
|
$lastProgress = [DateTime]::MinValue
|
||||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||||
$total += $count
|
$total += $count
|
||||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||||
$outputStream.Write($buffer,0,$count)
|
$outputStream.Write($buffer,0,$count)
|
||||||
|
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
||||||
|
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
||||||
|
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
||||||
|
Show-UpdateProgress $ProgressLabel $detail $percent
|
||||||
|
$lastProgress = [DateTime]::UtcNow
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
||||||
|
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
||||||
} finally {
|
} finally {
|
||||||
if ($outputStream) { $outputStream.Dispose() }
|
if ($outputStream) { $outputStream.Dispose() }
|
||||||
if ($inputStream) { $inputStream.Dispose() }
|
if ($inputStream) { $inputStream.Dispose() }
|
||||||
@@ -220,8 +411,9 @@ function Write-Result($Value) {
|
|||||||
if ($Library) { return }
|
if ($Library) { return }
|
||||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||||
$changed = [Collections.Generic.List[string]]::new()
|
$changed = [Collections.Generic.List[string]]::new()
|
||||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||||
try {
|
try {
|
||||||
|
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||||
$current = Get-StableVersion $CurrentVersion
|
$current = Get-StableVersion $CurrentVersion
|
||||||
if (!$current) { throw 'Invalid installed version.' }
|
if (!$current) { throw 'Invalid installed version.' }
|
||||||
if ($Mode -eq 'Install') {
|
if ($Mode -eq 'Install') {
|
||||||
@@ -242,25 +434,35 @@ try {
|
|||||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||||
Receive-File $release.zip $archive 50MB
|
Show-UpdateProgress 'Downloading update' $release.version 0
|
||||||
|
Receive-File $release.zip $archive 50MB 'Downloading update'
|
||||||
|
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
||||||
Receive-File $release.checksum $checksum 64KB
|
Receive-File $release.checksum $checksum 64KB
|
||||||
Assert-Checksum $archive $checksum
|
Assert-Checksum $archive $checksum
|
||||||
|
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||||
Write-Result @{state='prepared';version=$release.version}
|
Write-Result @{state='prepared';version=$release.version}
|
||||||
|
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||||
$ready.Set() | Out-Null
|
$ready.Set() | Out-Null
|
||||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||||
$parentExited = $true
|
$parentExited = $true
|
||||||
|
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||||
Install-Files $stage $InstallDirectory $backup $changed
|
Install-Files $stage $InstallDirectory $backup $changed
|
||||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||||
|
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
||||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||||
$success = $true
|
$success = $true
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
$message = $_.Exception.Message
|
$message = $_.Exception.Message
|
||||||
if ($parentExited) {
|
if ($parentExited) {
|
||||||
|
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||||
try {
|
try {
|
||||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||||
Undo-Files $InstallDirectory $backup $changed
|
Undo-Files $InstallDirectory $backup $changed
|
||||||
@@ -273,9 +475,13 @@ try {
|
|||||||
}
|
}
|
||||||
Write-Result @{state='error';error=$message}
|
Write-Result @{state='error';error=$message}
|
||||||
if ($ready -and $ready.WaitOne(0)) {
|
if ($ready -and $ready.WaitOne(0)) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||||
|
} else {
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
if ($ready) { $ready.Dispose() }
|
if ($ready) { $ready.Dispose() }
|
||||||
@@ -291,4 +497,8 @@ try {
|
|||||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
||||||
|
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-52
@@ -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,17 @@ namespace ks
|
|||||||
App *App::current = nullptr;
|
App *App::current = nullptr;
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
const char *transformCommandName(int command)
|
||||||
|
{
|
||||||
|
return command == Toggle ? "Convert / cycle" : command >= Direct ? "Convert / direct" :
|
||||||
|
command == Case ? "Change case" : command == Translit ? "Transliterate" :
|
||||||
|
command == Undo ? "Undo" : "Automatic / snippet";
|
||||||
|
}
|
||||||
|
ULONG_PTR snippetReplayMarker()
|
||||||
|
{
|
||||||
|
static const char marker{};
|
||||||
|
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||||
|
}
|
||||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||||
{
|
{
|
||||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||||
@@ -248,6 +261,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||||
}
|
}
|
||||||
setLoggingEnabled(settings.writeLog);
|
setLoggingEnabled(settings.writeLog);
|
||||||
|
if (settings.autoCorrect)
|
||||||
|
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
settings.autoCorrect = false;
|
||||||
|
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = std::make_unique<History>(data / L"history");
|
history = std::make_unique<History>(data / L"history");
|
||||||
@@ -417,12 +437,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
void App::applySettings(const Settings &s)
|
void App::applySettings(const Settings &s)
|
||||||
{
|
{
|
||||||
bool autostartChanged = false;
|
bool autostartChanged = false;
|
||||||
|
AutoCorrector predictor;
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
validateTesseract(s);
|
validateTesseract(s);
|
||||||
if (s.autoCorrect)
|
if (s.autoCorrect)
|
||||||
{
|
{
|
||||||
AutoCorrector probe;
|
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
|
||||||
}
|
}
|
||||||
if (!registerHotkeys(s))
|
if (!registerHotkeys(s))
|
||||||
{
|
{
|
||||||
@@ -454,6 +474,7 @@ void App::applySettings(const Settings &s)
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
settings = s;
|
settings = s;
|
||||||
|
autoPredictor = std::move(predictor);
|
||||||
setLoggingEnabled(s.writeLog);
|
setLoggingEnabled(s.writeLog);
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
@@ -517,6 +538,7 @@ void App::resetInput()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
|
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||||
{
|
{
|
||||||
if (ownKeyboardInput(k))
|
if (ownKeyboardInput(k))
|
||||||
@@ -557,9 +579,33 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.record(ch, fg, layout, GetTickCount64());
|
run.record(ch, fg, layout, GetTickCount64());
|
||||||
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() &&
|
||||||
|
mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||||
run.keys.size() <= 41 && !busy && !run.overflow)
|
run.keys.size() <= 41 && !busy && !run.overflow)
|
||||||
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
|
{
|
||||||
|
for (int source = 0; source < (int)layouts.size(); ++source)
|
||||||
|
if (layouts[source].handle == layout)
|
||||||
|
{
|
||||||
|
std::wstring predicted;
|
||||||
|
for (wchar_t key : run.keys)
|
||||||
|
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
|
||||||
|
if (autoPredictor.suggest(predicted, source, layouts, settings))
|
||||||
|
{
|
||||||
|
// Reserve the transaction before the next physical key arrives.
|
||||||
|
// The delimiter itself passes through; subsequent input is queued.
|
||||||
|
typingTarget = fg;
|
||||||
|
autoPending = true;
|
||||||
|
busy = true;
|
||||||
|
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
|
||||||
|
{
|
||||||
|
typingTarget = nullptr;
|
||||||
|
autoPending = false;
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (busy || settings.snippets.empty())
|
if (busy || settings.snippets.empty())
|
||||||
return;
|
return;
|
||||||
auto keys = lower(run.keys);
|
auto keys = lower(run.keys);
|
||||||
@@ -587,7 +633,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
for (auto &trigger : variants)
|
for (auto &trigger : variants)
|
||||||
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
||||||
{
|
{
|
||||||
PostMessageW(window, SnippetMessage, (WPARAM)i, 0);
|
// Keep subsequent typing out of the selection/paste transaction.
|
||||||
|
typingTarget = fg;
|
||||||
|
snippetPending = (int)i;
|
||||||
|
snippetTyping = true;
|
||||||
|
busy = true;
|
||||||
|
if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load()))
|
||||||
|
{
|
||||||
|
typingTarget = nullptr;
|
||||||
|
snippetPending = -1;
|
||||||
|
snippetTyping = false;
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,12 +718,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
bool App::beginTypingDeferral(HWND target, uint64_t version)
|
bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||||
return false;
|
return false;
|
||||||
typingTarget = target;
|
typingTarget = target;
|
||||||
|
snippetTyping = snippet;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
void App::replayTyping()
|
void App::replayTyping()
|
||||||
@@ -677,7 +735,13 @@ void App::replayTyping()
|
|||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
target = typingTarget;
|
target = typingTarget;
|
||||||
typingTarget = nullptr;
|
typingTarget = nullptr;
|
||||||
|
autoPending = false;
|
||||||
|
snippetPending = -1;
|
||||||
inputs.swap(deferredTyping);
|
inputs.swap(deferredTyping);
|
||||||
|
if (snippetTyping)
|
||||||
|
for (auto &input : inputs)
|
||||||
|
input.ki.dwExtraInfo = snippetReplayMarker();
|
||||||
|
snippetTyping = false;
|
||||||
}
|
}
|
||||||
busy = false;
|
busy = false;
|
||||||
if (inputs.empty())
|
if (inputs.empty())
|
||||||
@@ -793,12 +857,30 @@ void App::transform(int command, int snippet)
|
|||||||
L" version=" + std::to_wstring(currentVersion) +
|
L" version=" + std::to_wstring(currentVersion) +
|
||||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||||
}
|
}
|
||||||
if (busy.exchange(true))
|
const bool preDeferred = (command == AutoCorrect && autoPending) ||
|
||||||
|
(snippet >= 0 && snippetPending == snippet);
|
||||||
|
if (busy.exchange(true) && !preDeferred)
|
||||||
{
|
{
|
||||||
if (manual)
|
if (manual)
|
||||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
{
|
||||||
|
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||||
|
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
|
||||||
|
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
|
||||||
|
{"elapsedMs", GetTickCount64() - requestedAt}};
|
||||||
|
diagnosticOperation(record.dump());
|
||||||
|
}
|
||||||
|
catch (...) {}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (preDeferred)
|
||||||
|
{
|
||||||
|
autoPending = false;
|
||||||
|
snippetPending = -1;
|
||||||
|
}
|
||||||
Run recent;
|
Run recent;
|
||||||
std::optional<LastTransform> previous;
|
std::optional<LastTransform> previous;
|
||||||
Settings config;
|
Settings config;
|
||||||
@@ -813,7 +895,7 @@ void App::transform(int command, int snippet)
|
|||||||
version = inputVersion;
|
version = inputVersion;
|
||||||
}
|
}
|
||||||
enqueue(
|
enqueue(
|
||||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
|
||||||
{
|
{
|
||||||
struct BusyReset
|
struct BusyReset
|
||||||
{
|
{
|
||||||
@@ -827,10 +909,10 @@ void App::transform(int command, int snippet)
|
|||||||
else
|
else
|
||||||
b = false;
|
b = false;
|
||||||
}
|
}
|
||||||
} reset{busy, window};
|
} 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)
|
||||||
{
|
{
|
||||||
@@ -855,21 +937,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));
|
||||||
}
|
}
|
||||||
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
|
catch (...) {} // Logging must never abort a completed input transaction.
|
||||||
|
}
|
||||||
|
} 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 = [&]
|
||||||
@@ -905,14 +1000,15 @@ void App::transform(int command, int snippet)
|
|||||||
std::wstring predicted;
|
std::wstring predicted;
|
||||||
for (wchar_t key : recent.keys)
|
for (wchar_t key : recent.keys)
|
||||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||||
|
expected = predicted;
|
||||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||||
!autoCorrectionAllowed(target, config) || !valid())
|
!autoCorrectionAllowed(target, config) || !valid())
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reason = L"clipboard-snapshot";
|
reason = L"clipboard-snapshot";
|
||||||
if (command == AutoCorrect)
|
if (command == AutoCorrect || snippet >= 0)
|
||||||
{
|
{
|
||||||
if (!beginTypingDeferral(target, version))
|
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||||
return;
|
return;
|
||||||
reset.deferred = true;
|
reset.deferred = true;
|
||||||
}
|
}
|
||||||
@@ -944,9 +1040,12 @@ void App::transform(int command, int snippet)
|
|||||||
autoSelected = true;
|
autoSelected = true;
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
// Some editors first select only ") ". Extend by whole-word
|
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||||
// boundaries, then still verify against the complete tracked run.
|
// suffix of the tracked input, and still require a complete match.
|
||||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
for (int i = 0; i < 3 && tx.valid() &&
|
||||||
|
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
|
||||||
|
(autoClosingSelection(text, config) ||
|
||||||
|
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
|
||||||
{
|
{
|
||||||
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||||
break;
|
break;
|
||||||
@@ -1004,13 +1103,11 @@ void App::transform(int command, int snippet)
|
|||||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||||
if (!tx.select(sn.trigger.size()))
|
if (!tx.select(sn.trigger.size()))
|
||||||
return;
|
return;
|
||||||
|
autoSelected = true;
|
||||||
|
reason = L"snippet-verification-mismatch";
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
if (tail.match(text, available, physicalLayout()) < 0)
|
if (tail.match(text, available, physicalLayout()) < 0)
|
||||||
{
|
|
||||||
if (tx.valid())
|
|
||||||
chord(VK_RIGHT);
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
result = parseTemplate(sn.text).text;
|
result = parseTemplate(sn.text).text;
|
||||||
automatic = true;
|
automatic = true;
|
||||||
}
|
}
|
||||||
@@ -1092,6 +1189,7 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool sent = false;
|
bool sent = false;
|
||||||
|
PasteProbe probe(config.writeLog && manual && result != text, text);
|
||||||
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||||
L" changed=" + std::to_wstring(result != text));
|
L" changed=" + std::to_wstring(result != text));
|
||||||
if (result != text)
|
if (result != text)
|
||||||
@@ -1102,10 +1200,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)
|
||||||
@@ -1123,12 +1238,22 @@ void App::transform(int command, int snippet)
|
|||||||
if (snippet >= 0 && sent && tx.valid())
|
if (snippet >= 0 && sent && tx.valid())
|
||||||
{
|
{
|
||||||
auto t = parseTemplate(config.snippets[snippet].text);
|
auto t = parseTemplate(config.snippets[snippet].text);
|
||||||
if (!t.suffix.empty() && tx.select(caretSteps(t.suffix)))
|
if (!t.suffix.empty())
|
||||||
{
|
{
|
||||||
|
if (!tx.select(caretSteps(t.suffix))) return;
|
||||||
auto suffix = tx.copy();
|
auto suffix = tx.copy();
|
||||||
if (tx.valid())
|
if (!tx.valid()) return;
|
||||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
if (newlines(suffix) != newlines(t.suffix))
|
||||||
|
{
|
||||||
|
chord(VK_RIGHT);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (!chord(VK_LEFT)) return;
|
||||||
|
}
|
||||||
|
// Action markers run after insertion and cursor placement,
|
||||||
|
// before any physical typing buffered during expansion.
|
||||||
|
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
||||||
|
if (!chord(VK_RETURN)) break;
|
||||||
}
|
}
|
||||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||||
{
|
{
|
||||||
@@ -1387,7 +1512,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
|
|||||||
updateWorker = std::thread([this, interactive, install, version]
|
updateWorker = std::thread([this, interactive, install, version]
|
||||||
{
|
{
|
||||||
auto result = std::make_unique<UpdateResult>();
|
auto result = std::make_unique<UpdateResult>();
|
||||||
try { *result = runUpdate(install, version); }
|
try { *result = runUpdate(install, version, interactive); }
|
||||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||||
result->interactive = interactive;
|
result->interactive = interactive;
|
||||||
result->install = install;
|
result->install = install;
|
||||||
@@ -1476,13 +1601,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->captureHistory();
|
app->captureHistory();
|
||||||
return 0;
|
return 0;
|
||||||
case AutoCorrectMessage:
|
case AutoCorrectMessage:
|
||||||
if (app->installingUpdate) return 0;
|
if (app->autoPending)
|
||||||
app->pendingAutoVersion = (uint64_t)wp;
|
{
|
||||||
SetTimer(hwnd, 3, 250, nullptr);
|
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||||
|
app->transform(AutoCorrect);
|
||||||
|
else
|
||||||
|
app->replayTyping();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case SnippetMessage:
|
case SnippetMessage:
|
||||||
if (app->installingUpdate) return 0;
|
if (app->snippetPending == (int)wp)
|
||||||
|
{
|
||||||
|
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
|
||||||
app->transform(0, (int)wp);
|
app->transform(0, (int)wp);
|
||||||
|
else
|
||||||
|
app->replayTyping();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case HistoryMessage:
|
case HistoryMessage:
|
||||||
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
||||||
@@ -1504,7 +1638,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COPYDATA:
|
case WM_COPYDATA:
|
||||||
{
|
{
|
||||||
auto *data = (COPYDATASTRUCT *)lp;
|
auto *data = (COPYDATASTRUCT *)lp;
|
||||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
|
||||||
{
|
{
|
||||||
if (app->installingUpdate) return FALSE;
|
if (app->installingUpdate) return FALSE;
|
||||||
return configurationRequest(*app, (HWND)wp, *data);
|
return configurationRequest(*app, (HWND)wp, *data);
|
||||||
@@ -1534,13 +1668,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->dispatch(20);
|
app->dispatch(20);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (wp == 3)
|
|
||||||
{
|
|
||||||
KillTimer(hwnd, 3);
|
|
||||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
|
||||||
app->transform(AutoCorrect);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (wp == 2)
|
if (wp == 2)
|
||||||
{
|
{
|
||||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||||
@@ -1635,8 +1762,8 @@ int App::execute()
|
|||||||
SetTimer(window, 2, 10000, nullptr);
|
SetTimer(window, 2, 10000, nullptr);
|
||||||
else
|
else
|
||||||
SetTimer(window, 5, 5000, nullptr);
|
SetTimer(window, 5, 5000, nullptr);
|
||||||
if (keyboardHook && mouseHook)
|
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
||||||
confirmUpdatedStartup(updateStartupEvent);
|
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
try { registerScreenshotNotifications(); }
|
try { registerScreenshotNotifications(); }
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class App
|
|||||||
Settings settings;
|
Settings settings;
|
||||||
std::unique_ptr<History> history;
|
std::unique_ptr<History> history;
|
||||||
AutoCorrector autoCorrector;
|
AutoCorrector autoCorrector;
|
||||||
|
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||||
std::atomic<bool> busy{false}, suspended{false};
|
std::atomic<bool> busy{false}, suspended{false};
|
||||||
std::atomic<DWORD> ownSequence{0};
|
std::atomic<DWORD> ownSequence{0};
|
||||||
std::atomic<uint64_t> inputVersion{0};
|
std::atomic<uint64_t> inputVersion{0};
|
||||||
@@ -70,6 +71,7 @@ class App
|
|||||||
void applySettings(const Settings &);
|
void applySettings(const Settings &);
|
||||||
void reloadLayouts();
|
void reloadLayouts();
|
||||||
void clearHistory();
|
void clearHistory();
|
||||||
|
void clearLog(bool previous);
|
||||||
void setHotkeyCapture(HWND);
|
void setHotkeyCapture(HWND);
|
||||||
bool isHotkey(DWORD, UINT);
|
bool isHotkey(DWORD, UINT);
|
||||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
@@ -99,7 +101,6 @@ class App
|
|||||||
bool (*updateNotifier)(const std::wstring &){};
|
bool (*updateNotifier)(const std::wstring &){};
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
uint64_t pendingAutoVersion{};
|
|
||||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||||
ULONGLONG previousAttemptAt{};
|
ULONGLONG previousAttemptAt{};
|
||||||
HWND previousAttemptWindow{};
|
HWND previousAttemptWindow{};
|
||||||
@@ -107,8 +108,11 @@ class App
|
|||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
HWND typingTarget{};
|
HWND typingTarget{};
|
||||||
|
bool autoPending{};
|
||||||
|
int snippetPending = -1;
|
||||||
|
bool snippetTyping{};
|
||||||
std::vector<INPUT> deferredTyping;
|
std::vector<INPUT> deferredTyping;
|
||||||
bool beginTypingDeferral(HWND, uint64_t);
|
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||||
void replayTyping();
|
void replayTyping();
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||||
|
|||||||
+15
-4
@@ -152,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
suffix.insert(suffix.begin(), word.back());
|
suffix.insert(suffix.begin(), word.back());
|
||||||
word.pop_back();
|
word.pop_back();
|
||||||
}
|
}
|
||||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||||||
|
// Validate letters and capitalization after conversion, not before it.
|
||||||
|
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||||||
|
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||||||
|
suffix.size() > 6 || rejected.contains(lower(word)))
|
||||||
return {};
|
return {};
|
||||||
for (auto &never : settings.autoNever)
|
for (auto &never : settings.autoNever)
|
||||||
if (lower(never) == lower(word))
|
if (lower(never) == lower(word))
|
||||||
@@ -172,7 +176,12 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
if (d.rank(word) || personal(language, word))
|
if (d.rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
int matches = 0;
|
std::optional<std::wstring> matchedText;
|
||||||
|
auto priority = [&](int layout)
|
||||||
|
{
|
||||||
|
auto language = layoutLanguage(layouts[layout]);
|
||||||
|
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||||||
|
};
|
||||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||||
{
|
{
|
||||||
if (i == source || !layouts[i].convertible ||
|
if (i == source || !layouts[i].convertible ||
|
||||||
@@ -187,9 +196,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (++matches > 1)
|
if (matchedText && *matchedText != converted)
|
||||||
return {};
|
return {};
|
||||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
matchedText = converted;
|
||||||
|
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||||||
|
(!candidate || priority(i) < priority(candidate->target)))
|
||||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
|
|||||||
+12
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (wp == 21) showSnippets(*app);
|
if (wp == 21) showSnippets(*app);
|
||||||
|
else if (wp == 22) showLogs(*app);
|
||||||
else showSettings(*app);
|
else showSettings(*app);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||||
|
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||||
PostQuitMessage(0);
|
PostQuitMessage(0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,7 @@ App::~App()
|
|||||||
{
|
{
|
||||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||||
|
if (logWindow) DestroyWindow(logWindow);
|
||||||
if (window) DestroyWindow(window);
|
if (window) DestroyWindow(window);
|
||||||
current = nullptr;
|
current = nullptr;
|
||||||
}
|
}
|
||||||
@@ -120,9 +123,9 @@ void App::setHotkeyCapture(HWND field)
|
|||||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||||
|
|
||||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||||
{
|
{
|
||||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||||
@@ -140,7 +143,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||||
AllowSetForegroundWindow(pid);
|
AllowSetForegroundWindow(pid);
|
||||||
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
@@ -169,9 +172,10 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||||
if (snippets) showSnippets(app); else showSettings(app);
|
setLoggingEnabled(app.settings.writeLog);
|
||||||
|
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
||||||
MSG message;
|
MSG message;
|
||||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||||
{
|
{
|
||||||
@@ -180,6 +184,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
}
|
}
|
||||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||||
|
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
{
|
{
|
||||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||||
|
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||||
LocalFree(args);
|
LocalFree(args);
|
||||||
result = ks::runConfigurator(instance, snippets);
|
result = ks::runConfigurator(instance, snippets, logs);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
|||||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||||
}
|
}
|
||||||
void launchConfiguration(App &app, bool snippets)
|
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||||
{
|
{
|
||||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||||
@@ -80,6 +80,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
case ConfigLogs:
|
case ConfigLogs:
|
||||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||||
break;
|
break;
|
||||||
|
case ConfigClearLog:
|
||||||
|
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
||||||
|
throw std::runtime_error("Invalid log selection.");
|
||||||
|
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
||||||
|
break;
|
||||||
default: return FALSE;
|
default: return FALSE;
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ namespace ks
|
|||||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||||
ConfigReply = ConfigRead + 6;
|
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
|
||||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
bool configurationPeer(HWND, const wchar_t *filename);
|
||||||
void launchConfiguration(App &, bool snippets = false);
|
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -261,7 +261,17 @@ Template parseTemplate(const std::wstring &text)
|
|||||||
size_t cursor = std::wstring::npos;
|
size_t cursor = std::wstring::npos;
|
||||||
for (size_t i = 0; i < text.size();)
|
for (size_t i = 0; i < text.size();)
|
||||||
{
|
{
|
||||||
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
if (text.compare(i, 9, L"{{enter}}") == 0)
|
||||||
|
{
|
||||||
|
t.text += L"{enter}";
|
||||||
|
i += 9;
|
||||||
|
}
|
||||||
|
else if (text.compare(i, 7, L"{enter}") == 0)
|
||||||
|
{
|
||||||
|
++t.enters;
|
||||||
|
i += 7;
|
||||||
|
}
|
||||||
|
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||||
{
|
{
|
||||||
t.text += L"{cursor}";
|
t.text += L"{cursor}";
|
||||||
i += 10;
|
i += 10;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct Template
|
|||||||
{
|
{
|
||||||
std::wstring text, suffix;
|
std::wstring text, suffix;
|
||||||
int cursors = 0;
|
int cursors = 0;
|
||||||
|
int enters = 0;
|
||||||
};
|
};
|
||||||
Template parseTemplate(const std::wstring &);
|
Template parseTemplate(const std::wstring &);
|
||||||
struct Hotkey
|
struct Hotkey
|
||||||
|
|||||||
@@ -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,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 (...)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,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
|
||||||
|
|||||||
+75
-2
@@ -178,6 +178,11 @@ struct Capture
|
|||||||
COLORREF currentColor=RGB(225,42,50);
|
COLORREF currentColor=RGB(225,42,50);
|
||||||
COLORREF customColors[16]{};
|
COLORREF customColors[16]{};
|
||||||
Mark stroke{};
|
Mark stroke{};
|
||||||
|
bool strokeConstrained{};
|
||||||
|
size_t strokeAnchor{};
|
||||||
|
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
|
||||||
|
bool strokeAxisSettled{};
|
||||||
|
std::vector<POINT> strokeMotion;
|
||||||
POINT start{}, end{};
|
POINT start{}, end{};
|
||||||
RECT selection{}, view{};
|
RECT selection{}, view{};
|
||||||
std::vector<Mark> marks;
|
std::vector<Mark> marks;
|
||||||
@@ -250,7 +255,7 @@ void updateStatus(Capture &c)
|
|||||||
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
||||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||||
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
|
else if(c.freehand) text=L"Freehand (F): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
|
||||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
||||||
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||||
if(text!=c.statusText) {
|
if(text!=c.statusText) {
|
||||||
@@ -841,7 +846,7 @@ void edit(HWND w, Capture &c)
|
|||||||
InitCommonControlsEx(&statusControls);
|
InitCommonControlsEx(&statusControls);
|
||||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||||
static_assert(std::size(labels)==std::size(ToolIds));
|
static_assert(std::size(labels)==std::size(ToolIds));
|
||||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||||
@@ -1045,6 +1050,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
|
|
||||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
case WM_KEYDOWN:
|
case WM_KEYDOWN:
|
||||||
|
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
|
||||||
|
c->strokeConstrained=true;
|
||||||
|
c->strokeAnchor=c->stroke.points.size()-1;
|
||||||
|
c->strokeAxis=0;
|
||||||
|
c->strokeAxisSettled=false;
|
||||||
|
c->strokeMotion={c->stroke.points.back()};
|
||||||
|
}
|
||||||
if (wp==VK_ESCAPE) {
|
if (wp==VK_ESCAPE) {
|
||||||
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1100,6 +1112,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
case WM_KEYUP:
|
||||||
|
if(wp==VK_SHIFT) c->strokeConstrained=false;
|
||||||
|
return 0;
|
||||||
case WM_LBUTTONDBLCLK:
|
case WM_LBUTTONDBLCLK:
|
||||||
if(c->editing && !c->dragging) {
|
if(c->editing && !c->dragging) {
|
||||||
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||||
@@ -1156,6 +1171,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
|
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
|
||||||
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||||
c->stroke.points.push_back(p);
|
c->stroke.points.push_back(p);
|
||||||
|
c->strokeConstrained=false;
|
||||||
}
|
}
|
||||||
c->dragging=true; SetCapture(w);
|
c->dragging=true; SetCapture(w);
|
||||||
}
|
}
|
||||||
@@ -1193,6 +1209,63 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
|
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
|
||||||
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
|
||||||
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
|
||||||
|
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
|
||||||
|
if(constrained) {
|
||||||
|
if(!c->strokeConstrained) {
|
||||||
|
c->strokeAnchor=c->stroke.points.size()-1;
|
||||||
|
c->strokeAxis=0;
|
||||||
|
c->strokeAxisSettled=false;
|
||||||
|
c->strokeMotion={c->stroke.points.back()};
|
||||||
|
}
|
||||||
|
auto anchor=c->stroke.points[c->strokeAnchor];
|
||||||
|
// Measure the actual mouse trajectory, not its snapped projection.
|
||||||
|
auto raw=c->end;
|
||||||
|
if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y)
|
||||||
|
c->strokeMotion.push_back(raw);
|
||||||
|
while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 ||
|
||||||
|
std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16))
|
||||||
|
c->strokeMotion.erase(c->strokeMotion.begin());
|
||||||
|
auto chooseAxis=[](POINT from,POINT to) {
|
||||||
|
LONG dx=to.x-from.x,dy=to.y-from.y;
|
||||||
|
constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees)
|
||||||
|
if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1;
|
||||||
|
if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2;
|
||||||
|
return (dx<0)==(dy<0)?3:4;
|
||||||
|
};
|
||||||
|
auto distance=[](int axis,POINT from,POINT to) {
|
||||||
|
double dx=to.x-from.x,dy=to.y-from.y;
|
||||||
|
return axis==1?std::abs(dy):axis==2?std::abs(dx):
|
||||||
|
std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0);
|
||||||
|
};
|
||||||
|
if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) {
|
||||||
|
c->strokeAxis=chooseAxis(anchor,c->end);
|
||||||
|
// A one-pixel first movement must not lock a diagonal gesture to an axis.
|
||||||
|
c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8;
|
||||||
|
}
|
||||||
|
else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) {
|
||||||
|
// Keep the completed segment and turn at its endpoint.
|
||||||
|
auto corner=c->stroke.points.back();
|
||||||
|
int nextAxis=chooseAxis(c->strokeMotion.front(),raw);
|
||||||
|
if(nextAxis!=c->strokeAxis) {
|
||||||
|
c->strokeAnchor=c->stroke.points.size()-1;
|
||||||
|
anchor=corner;
|
||||||
|
c->strokeAxis=nextAxis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(c->strokeAxis==1) c->end.y=anchor.y;
|
||||||
|
else if(c->strokeAxis==2) c->end.x=anchor.x;
|
||||||
|
else if(c->strokeAxis>=3) {
|
||||||
|
int sign=c->strokeAxis==3?1:-1;
|
||||||
|
LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0);
|
||||||
|
LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1);
|
||||||
|
LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y;
|
||||||
|
delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY));
|
||||||
|
c->end={anchor.x+delta,anchor.y+sign*delta};
|
||||||
|
}
|
||||||
|
// Only the current segment is a preview; earlier corners stay fixed.
|
||||||
|
c->stroke.points.resize(c->strokeAnchor+1);
|
||||||
|
}
|
||||||
|
c->strokeConstrained=constrained;
|
||||||
auto last=c->stroke.points.back();
|
auto last=c->stroke.points.back();
|
||||||
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
||||||
c->stroke.to=c->end;
|
c->stroke.to=c->end;
|
||||||
|
|||||||
+68
-260
@@ -4,6 +4,7 @@
|
|||||||
#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,6 +35,8 @@ 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{}, details{};
|
||||||
|
bool autoOptions{};
|
||||||
};
|
};
|
||||||
WindowLayout *windowLayoutState(HWND w)
|
WindowLayout *windowLayoutState(HWND w)
|
||||||
{
|
{
|
||||||
@@ -69,8 +72,9 @@ void arrangeWindow(HWND w)
|
|||||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||||
}
|
}
|
||||||
RECT client{}; GetClientRect(w, &client);
|
RECT client{}; GetClientRect(w, &client);
|
||||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||||
|
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||||
struct Move { HWND child; RECT rect; };
|
struct Move { HWND child; RECT rect; };
|
||||||
std::vector<Move> moves;
|
std::vector<Move> moves;
|
||||||
for (const auto &[child, r] : layout->controls)
|
for (const auto &[child, r] : layout->controls)
|
||||||
@@ -80,6 +84,40 @@ void arrangeWindow(HWND w)
|
|||||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
||||||
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
||||||
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
||||||
|
if (child == layout->stretch)
|
||||||
|
{
|
||||||
|
next.right += extraWidth;
|
||||||
|
next.bottom += extraHeight;
|
||||||
|
}
|
||||||
|
if (child == layout->details)
|
||||||
|
{
|
||||||
|
next.right += extraWidth;
|
||||||
|
OffsetRect(&next, 0, extraHeight);
|
||||||
|
}
|
||||||
|
if (child == layout->bottomLeft || child == layout->bottomRight)
|
||||||
|
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
||||||
|
if (layout->autoOptions)
|
||||||
|
{
|
||||||
|
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
|
||||||
|
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
|
||||||
|
{
|
||||||
|
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
|
||||||
|
int leftExtra = extraWidth * column / 3;
|
||||||
|
OffsetRect(&next, leftExtra, 0);
|
||||||
|
next.right += extraWidth * (column + 1) / 3 - leftExtra;
|
||||||
|
if (r.top == 328) next.bottom += extraHeight;
|
||||||
|
}
|
||||||
|
else if (r.top == 474 || r.top == 500)
|
||||||
|
{
|
||||||
|
bool rightColumn = r.left >= 290;
|
||||||
|
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
|
||||||
|
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
|
||||||
|
}
|
||||||
|
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
|
||||||
|
OffsetRect(&next, extraWidth, 0);
|
||||||
|
else if (r.right - r.left >= 500)
|
||||||
|
next.right += extraWidth;
|
||||||
|
}
|
||||||
RECT current{}; GetWindowRect(child, ¤t);
|
RECT current{}; GetWindowRect(child, ¤t);
|
||||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
||||||
@@ -279,7 +317,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());
|
||||||
}
|
}
|
||||||
@@ -1064,6 +1102,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
{
|
{
|
||||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||||
560, 680);
|
560, 680);
|
||||||
|
windowLayoutState(w)->autoOptions = true;
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||||
@@ -1104,6 +1143,11 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
|
||||||
button(w, L"OK", IDOK, 326, 632, 100);
|
button(w, L"OK", IDOK, 326, 632, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
button(w, L"Cancel", IDCANCEL, 440, 632, 100);
|
||||||
|
RECT initial{};
|
||||||
|
GetWindowRect(w, &initial);
|
||||||
|
initial.right += scale(w, 200);
|
||||||
|
initial.bottom += scale(w, 140);
|
||||||
|
fitToMonitor(w, initial);
|
||||||
EnableWindow(parent, FALSE);
|
EnableWindow(parent, FALSE);
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
@@ -1337,6 +1381,14 @@ struct SnippetView
|
|||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
int selection = -1;
|
int selection = -1;
|
||||||
};
|
};
|
||||||
|
void updateSnippetEditor(HWND w, SnippetView *v)
|
||||||
|
{
|
||||||
|
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
||||||
|
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
|
||||||
|
value(w, 303, selected ? v->snippets[v->selection].text : L"");
|
||||||
|
for (int id : {302, 303, 311, 312, 313})
|
||||||
|
EnableWindow(GetDlgItem(w, id), selected);
|
||||||
|
}
|
||||||
void fillSnippets(HWND w, SnippetView *v)
|
void fillSnippets(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
||||||
@@ -1344,6 +1396,7 @@ void fillSnippets(HWND w, SnippetView *v)
|
|||||||
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
||||||
if (v->selection >= 0)
|
if (v->selection >= 0)
|
||||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||||
|
updateSnippetEditor(w, v);
|
||||||
}
|
}
|
||||||
void collectSnippet(HWND w, SnippetView *v)
|
void collectSnippet(HWND w, SnippetView *v)
|
||||||
{
|
{
|
||||||
@@ -1380,11 +1433,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
collectSnippet(w, v);
|
collectSnippet(w, v);
|
||||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||||
if (v->selection >= 0)
|
updateSnippetEditor(w, v);
|
||||||
{
|
|
||||||
value(w, 302, v->snippets[v->selection].trigger);
|
|
||||||
value(w, 303, v->snippets[v->selection].text);
|
|
||||||
}
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 310)
|
if (id == 310)
|
||||||
@@ -1393,8 +1442,6 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->snippets.push_back({L";new", L""});
|
v->snippets.push_back({L";new", L""});
|
||||||
v->selection = (int)v->snippets.size() - 1;
|
v->selection = (int)v->snippets.size() - 1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
value(w, 302, L";new");
|
|
||||||
value(w, 303, L"");
|
|
||||||
SetFocus(GetDlgItem(w, 302));
|
SetFocus(GetDlgItem(w, 302));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1405,14 +1452,14 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||||
v->selection = -1;
|
v->selection = -1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
value(w, 302, L"");
|
SetFocus(GetDlgItem(w, 301));
|
||||||
value(w, 303, L"");
|
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 312)
|
if (id == 312 || id == 313)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)L"{cursor}");
|
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
|
||||||
|
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == IDOK)
|
if (id == IDOK)
|
||||||
@@ -1530,251 +1577,11 @@ void showHistory(App &app, bool visible)
|
|||||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||||
refreshHistory(app);
|
refreshHistory(app);
|
||||||
}
|
}
|
||||||
namespace
|
#endif
|
||||||
{
|
#ifndef KS_RESIDENT_APP
|
||||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
#include "ui_log.inl"
|
||||||
LogClear = 606;
|
#else
|
||||||
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||||
{
|
|
||||||
// Header notifications go to the ListView, not its top-level parent.
|
|
||||||
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
|
|
||||||
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
|
|
||||||
{
|
|
||||||
auto *draw = (NMCUSTOMDRAW *)lp;
|
|
||||||
if (draw->dwDrawStage == CDDS_PREPAINT)
|
|
||||||
{
|
|
||||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
|
||||||
return CDRF_NOTIFYITEMDRAW;
|
|
||||||
}
|
|
||||||
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
|
|
||||||
{
|
|
||||||
int saved = SaveDC(draw->hdc);
|
|
||||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
|
||||||
RECT edge = draw->rc;
|
|
||||||
edge.top = edge.bottom - 1;
|
|
||||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
|
||||||
edge = draw->rc;
|
|
||||||
edge.left = edge.right - 1;
|
|
||||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
|
||||||
wchar_t title[128]{};
|
|
||||||
HDITEMW item{};
|
|
||||||
item.mask = HDI_TEXT;
|
|
||||||
item.pszText = title;
|
|
||||||
item.cchTextMax = 128;
|
|
||||||
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
|
|
||||||
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
|
|
||||||
if (headerFont)
|
|
||||||
SelectObject(draw->hdc, headerFont);
|
|
||||||
SetBkMode(draw->hdc, TRANSPARENT);
|
|
||||||
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
|
|
||||||
RECT text = draw->rc;
|
|
||||||
InflateRect(&text, -scale(w, 6), 0);
|
|
||||||
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
|
||||||
RestoreDC(draw->hdc, saved);
|
|
||||||
return CDRF_SKIPDEFAULT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg == WM_NCDESTROY)
|
|
||||||
RemoveWindowSubclass(w, logListProc, 1);
|
|
||||||
return DefSubclassProc(w, msg, wp, lp);
|
|
||||||
}
|
|
||||||
void copyLogRows(HWND w, App &app)
|
|
||||||
{
|
|
||||||
HWND list = GetDlgItem(w, LogRows);
|
|
||||||
std::wstring copied;
|
|
||||||
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
|
||||||
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
|
||||||
{
|
|
||||||
for (int column = 0; column < 4; ++column)
|
|
||||||
{
|
|
||||||
std::vector<wchar_t> text(32768);
|
|
||||||
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
|
|
||||||
if (column)
|
|
||||||
copied += L'\t';
|
|
||||||
copied += text.data();
|
|
||||||
}
|
|
||||||
copied += L"\r\n";
|
|
||||||
}
|
|
||||||
if (!copied.empty())
|
|
||||||
app.ownSequence = writeClipboard(app.window, textClip(copied));
|
|
||||||
}
|
|
||||||
void refreshLogView(HWND w, App &app)
|
|
||||||
{
|
|
||||||
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
|
|
||||||
std::wstring contents;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
contents = readDiagnosticLog(previous);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e)
|
|
||||||
{
|
|
||||||
contents = L"Could not read the log: " + wide(e.what());
|
|
||||||
}
|
|
||||||
HWND list = GetDlgItem(w, LogRows);
|
|
||||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
|
||||||
ListView_DeleteAllItems(list);
|
|
||||||
std::wistringstream lines(contents);
|
|
||||||
std::wstring line, timestamp;
|
|
||||||
int row = 0;
|
|
||||||
while (std::getline(lines, line))
|
|
||||||
{
|
|
||||||
if (!line.empty() && line.back() == L'\r')
|
|
||||||
line.pop_back();
|
|
||||||
if (line.empty())
|
|
||||||
continue;
|
|
||||||
auto firstSpace = line.find(L' ');
|
|
||||||
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
|
|
||||||
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
|
|
||||||
{
|
|
||||||
timestamp = line.substr(0, secondSpace);
|
|
||||||
line.erase(0, secondSpace + 1);
|
|
||||||
}
|
|
||||||
auto id = line.find(L" id=");
|
|
||||||
std::wstring attempt;
|
|
||||||
if (id != line.npos)
|
|
||||||
{
|
|
||||||
auto end = line.find(L' ', id + 4);
|
|
||||||
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
|
|
||||||
}
|
|
||||||
std::wstring event = line;
|
|
||||||
if (event.starts_with(L" ageMs="))
|
|
||||||
{
|
|
||||||
auto end = event.find(L' ', 8);
|
|
||||||
if (end != event.npos)
|
|
||||||
event.erase(0, end + 1);
|
|
||||||
}
|
|
||||||
auto equals = event.find(L'=');
|
|
||||||
if (equals != event.npos)
|
|
||||||
{
|
|
||||||
auto key = event.rfind(L' ', equals);
|
|
||||||
if (key != event.npos)
|
|
||||||
event.resize(key);
|
|
||||||
}
|
|
||||||
LVITEMW item{};
|
|
||||||
item.mask = LVIF_TEXT;
|
|
||||||
item.iItem = row;
|
|
||||||
item.pszText = timestamp.data();
|
|
||||||
ListView_InsertItem(list, &item);
|
|
||||||
ListView_SetItemText(list, row, 1, attempt.data());
|
|
||||||
ListView_SetItemText(list, row, 2, event.data());
|
|
||||||
ListView_SetItemText(list, row, 3, line.data());
|
|
||||||
++row;
|
|
||||||
}
|
|
||||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
|
||||||
InvalidateRect(list, nullptr, TRUE);
|
|
||||||
if (row)
|
|
||||||
ListView_EnsureVisible(list, row - 1, FALSE);
|
|
||||||
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
|
||||||
: L"Logging disabled. Showing previously saved entries.");
|
|
||||||
}
|
|
||||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|
||||||
{
|
|
||||||
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
|
||||||
if (msg == WM_NCCREATE)
|
|
||||||
{
|
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
if (!app)
|
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
|
||||||
switch (msg)
|
|
||||||
{
|
|
||||||
case WM_COMMAND:
|
|
||||||
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
|
||||||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
|
||||||
{
|
|
||||||
refreshLogView(w, *app);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
|
||||||
{
|
|
||||||
try { copyLogRows(w, *app); }
|
|
||||||
catch (const std::exception &e) { error(w, e); }
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
|
||||||
refreshLogView(w, *app);
|
|
||||||
}
|
|
||||||
catch (const std::exception &e) { error(w, e); }
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
|
||||||
break;
|
|
||||||
[[fallthrough]];
|
|
||||||
case WM_CLOSE:
|
|
||||||
DestroyWindow(w);
|
|
||||||
return 0;
|
|
||||||
case WM_NOTIFY:
|
|
||||||
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
|
|
||||||
{
|
|
||||||
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
|
||||||
if (GetKeyState(VK_CONTROL) & 0x8000)
|
|
||||||
{
|
|
||||||
if (key == 'A')
|
|
||||||
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
|
||||||
if (key == 'C')
|
|
||||||
{
|
|
||||||
try { copyLogRows(w, *app); }
|
|
||||||
catch (const std::exception &e) { error(w, e); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
case WM_DPICHANGED:
|
|
||||||
resizeForDpi(w, wp, lp);
|
|
||||||
return 0;
|
|
||||||
case WM_NCDESTROY:
|
|
||||||
app->logWindow = nullptr;
|
|
||||||
disposeFont(w);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void showLogs(App &app)
|
|
||||||
{
|
|
||||||
if (app.logWindow)
|
|
||||||
{
|
|
||||||
showRequestedWindow(app.logWindow);
|
|
||||||
activateWindow(app.logWindow);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
|
|
||||||
app.logWindow = w;
|
|
||||||
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
|
|
||||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
|
||||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
|
||||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
|
||||||
button(w, L"Refresh", LogRefresh, 330, 14, 110);
|
|
||||||
button(w, L"Copy selected", LogCopy, 454, 14, 140);
|
|
||||||
button(w, L"Clear log", LogClear, 608, 14, 110);
|
|
||||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
|
|
||||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
|
||||||
InitCommonControlsEx(&common);
|
|
||||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
|
||||||
LogRows, 16, 82, 868, 454);
|
|
||||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
|
||||||
SetWindowSubclass(list, logListProc, 1, 0);
|
|
||||||
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
|
||||||
const int widths[] = {155, 65, 195, 900};
|
|
||||||
for (int i = 0; i < 4; ++i)
|
|
||||||
{
|
|
||||||
LVCOLUMNW column{};
|
|
||||||
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
|
||||||
column.pszText = const_cast<wchar_t *>(titles[i]);
|
|
||||||
column.cx = scale(w, widths[i]);
|
|
||||||
ListView_InsertColumn(list, i, &column);
|
|
||||||
}
|
|
||||||
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
|
|
||||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
|
||||||
refreshLogView(w, app);
|
|
||||||
showRequestedWindow(w);
|
|
||||||
activateWindow(w);
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
#ifndef KS_RESIDENT_APP
|
#ifndef KS_RESIDENT_APP
|
||||||
void showSettings(App &app, bool visible)
|
void showSettings(App &app, bool visible)
|
||||||
@@ -1861,8 +1668,9 @@ void showSnippets(App &app)
|
|||||||
edit(w, 302, 236, 44, 488);
|
edit(w, 302, 236, 44, 488);
|
||||||
label(w, L"Replacement text", 236, 82, 480);
|
label(w, L"Replacement text", 236, 82, 480);
|
||||||
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
label(w, L"Use {cursor} once; {{cursor}} inserts the literal marker.", 236, 392, 488);
|
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
|
||||||
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
button(w, L"Insert cursor marker", 312, 236, 430, 190);
|
||||||
|
button(w, L"Insert Enter marker", 313, 436, 430, 190);
|
||||||
button(w, L"Save", IDOK, 510, 478, 100);
|
button(w, L"Save", IDOK, 510, 478, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
|||||||
+357
@@ -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);
|
||||||
|
}
|
||||||
+5
-4
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
|
|||||||
return out + L'"';
|
return out + L'"';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
|
||||||
{
|
{
|
||||||
UpdateResult result;
|
UpdateResult result;
|
||||||
GUID guid{};
|
GUID guid{};
|
||||||
@@ -56,6 +56,7 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
|||||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||||
|
if (showProgress) command += L" -ShowProgress";
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||||
@@ -94,10 +95,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void confirmUpdatedStartup(const std::wstring &eventName)
|
bool confirmUpdatedStartup(const std::wstring &eventName)
|
||||||
{
|
{
|
||||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
|
||||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||||
if (event.value) SetEvent(event.value);
|
return event.value && SetEvent(event.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.10";
|
inline constexpr wchar_t AppVersion[] = L"0.6.18";
|
||||||
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;
|
||||||
@@ -13,6 +13,6 @@ struct UpdateResult
|
|||||||
std::wstring state, version, error;
|
std::wstring state, version, error;
|
||||||
bool interactive{}, install{};
|
bool interactive{}, install{};
|
||||||
};
|
};
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
|
||||||
void confirmUpdatedStartup(const std::wstring &eventName);
|
bool confirmUpdatedStartup(const std::wstring &eventName);
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-2
@@ -1,4 +1,4 @@
|
|||||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
|
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||||
@@ -18,6 +18,7 @@ public static class ConfigurationTestUi {
|
|||||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
||||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
||||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
||||||
|
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
|
||||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
||||||
@@ -38,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'}}
|
$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
|
||||||
@@ -56,6 +57,24 @@ function Launch($file,$argument) {
|
|||||||
function Click($window,$id) {
|
function Click($window,$id) {
|
||||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
||||||
}
|
}
|
||||||
|
function TestLogs {
|
||||||
|
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
|
||||||
|
$config=Launch 'KSwitcher.Config.exe' '--logs'
|
||||||
|
$logs=Window 'KSwitcher.Logs'
|
||||||
|
[uint32]$logOwner=0
|
||||||
|
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
|
||||||
|
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
|
||||||
|
$second=Launch 'KSwitcher.Config.exe' '--logs'
|
||||||
|
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
|
||||||
|
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
|
||||||
|
Click $logs 603
|
||||||
|
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
|
||||||
|
Click $logs 606
|
||||||
|
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
|
||||||
|
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
|
||||||
|
Click $logs 2
|
||||||
|
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||||
@@ -67,6 +86,13 @@ try {
|
|||||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||||
$resident=Launch 'KSwitcher.exe' ''
|
$resident=Launch 'KSwitcher.exe' ''
|
||||||
$main=Window 'KSwitcher.Main'
|
$main=Window 'KSwitcher.Main'
|
||||||
|
if ($LogsOnly) {
|
||||||
|
TestLogs
|
||||||
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
|
||||||
|
"Passed $checks log process checks"
|
||||||
|
return
|
||||||
|
}
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
[uint32]$ownerId=0
|
[uint32]$ownerId=0
|
||||||
@@ -102,6 +128,29 @@ try {
|
|||||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||||
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
||||||
$snippets=Window 'KSwitcher.Snippets'
|
$snippets=Window 'KSwitcher.Snippets'
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
foreach($id in @(302,303,311,312,313)) {
|
||||||
|
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
|
||||||
|
}
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||||
|
foreach($id in @(302,303,311,312,313)) {
|
||||||
|
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
|
||||||
|
}
|
||||||
|
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
|
||||||
|
foreach($id in @(302,303,311,312,313)) {
|
||||||
|
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
|
||||||
|
}
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
|
||||||
|
$snippetText=[Text.StringBuilder]::new(128)
|
||||||
|
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||||
|
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
|
||||||
|
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
|
||||||
|
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||||
|
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
|
||||||
$second=Launch 'KSwitcher.Config.exe' ''
|
$second=Launch 'KSwitcher.Config.exe' ''
|
||||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
@@ -110,6 +159,7 @@ try {
|
|||||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||||
Click $snippets 2
|
Click $snippets 2
|
||||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||||
|
TestLogs
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$null=Window 'KSwitcher.Settings'
|
$null=Window 'KSwitcher.Settings'
|
||||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ int main(int argc, char **argv) {
|
|||||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||||
require(app.window!=nullptr,"message-only test window");
|
require(app.window!=nullptr,"message-only test window");
|
||||||
|
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
||||||
|
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
||||||
|
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
||||||
|
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
|
||||||
|
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
|
||||||
|
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
|
||||||
|
"successful startup signals the updater before reporting completion");
|
||||||
|
CloseHandle(started);
|
||||||
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
||||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
||||||
@@ -590,6 +598,35 @@ int main(int argc, char **argv) {
|
|||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
require(autoOptions!=nullptr,"auto-correction options open");
|
require(autoOptions!=nullptr,"auto-correction options open");
|
||||||
ShowWindow(autoOptions,SW_HIDE);
|
ShowWindow(autoOptions,SW_HIDE);
|
||||||
|
{
|
||||||
|
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
|
||||||
|
"auto-correction dialog supports resizing");
|
||||||
|
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
|
||||||
|
GetWindowRect(autoOptions,&original);
|
||||||
|
// Use a client size above the scrolling baseline at every DPI.
|
||||||
|
UINT dpi=GetDpiForWindow(autoOptions);
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
||||||
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||||
|
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||||
|
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
for(int i=0;i<3;++i) {
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||||
|
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
||||||
|
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
||||||
|
"dictionary columns share extra width and receive extra height");
|
||||||
|
}
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
||||||
|
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
|
||||||
|
"auto-correction buttons follow bottom-right corner");
|
||||||
|
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||||
|
after[2].bottom<buttonAfter.top,
|
||||||
|
"resized dictionary fields and footer do not overlap");
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
|
||||||
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
for(int id:{411,412,413}) {
|
for(int id:{411,412,413}) {
|
||||||
RECT control{},client{};
|
RECT control{},client{};
|
||||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||||
|
|||||||
+230
-8
@@ -1,4 +1,6 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -20,6 +22,19 @@ ks::App *app{};
|
|||||||
HWND host{}, field{};
|
HWND host{}, field{};
|
||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
bool captured = false, failed = false;
|
bool captured = false, failed = false;
|
||||||
|
bool conversionOnly = false;
|
||||||
|
int pasteBehavior = 0;
|
||||||
|
int enterPresses = 0;
|
||||||
|
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
|
{
|
||||||
|
if (message == WM_PASTE && pasteBehavior)
|
||||||
|
{
|
||||||
|
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||||||
|
return DefSubclassProc(w, message, wp, lp);
|
||||||
|
}
|
||||||
int stage = 0, ru = -1, en = -1;
|
int stage = 0, ru = -1, en = -1;
|
||||||
size_t punctuationCase = 0;
|
size_t punctuationCase = 0;
|
||||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||||
@@ -171,12 +186,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");
|
||||||
@@ -280,7 +303,112 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 17:
|
case 17:
|
||||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||||
[[fallthrough]];
|
stage = 20;
|
||||||
|
break;
|
||||||
|
case 40:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||||||
|
"blocked paste records original text remaining in the actual editor");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||||||
|
pasteBehavior = 2;
|
||||||
|
prepare(L"ghbdtn", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 41:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||||
|
"unexpected editor content records readback mismatch");
|
||||||
|
pasteBehavior = 0;
|
||||||
|
prepare(L"привет", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 42:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
|
||||||
|
"manual unchanged conversion is recorded as a problem");
|
||||||
|
diagnosticStart = diagnosticLog().size();
|
||||||
|
app->settings.writeLog = false;
|
||||||
|
ks::setLoggingEnabled(false);
|
||||||
|
prepare(L"ghbdtn", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 43:
|
||||||
|
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
||||||
|
"disabled diagnostics preserves conversion and writes no records");
|
||||||
|
ks::setLoggingEnabled(true);
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
|
case 30:
|
||||||
|
case 32:
|
||||||
|
case 34:
|
||||||
|
if (!captured)
|
||||||
|
{
|
||||||
|
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
|
captured = true;
|
||||||
|
}
|
||||||
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
|
app->settings.autoCorrect = stage == 35;
|
||||||
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||||||
|
enterPresses = 0;
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"prefix ", false);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
const std::vector<WORD> keys = stage == 35
|
||||||
|
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
|
||||||
|
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
|
||||||
|
for (WORD key : keys)
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"snippet trigger and following text submitted without a pause");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 31:
|
||||||
|
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
|
||||||
|
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
|
||||||
|
stage = 32;
|
||||||
|
break;
|
||||||
|
case 33:
|
||||||
|
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
||||||
|
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
||||||
|
stage = 34;
|
||||||
|
break;
|
||||||
|
case 35:
|
||||||
|
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
|
||||||
|
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
|
||||||
|
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"GHBDTN "))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"normal typing submitted after snippet completes");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 36:
|
||||||
|
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
case 20:
|
case 20:
|
||||||
if (!captured)
|
if (!captured)
|
||||||
{
|
{
|
||||||
@@ -315,7 +443,44 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 19:
|
case 19:
|
||||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||||
"rejected correction collapses selection before replaying typing in original layout");
|
"rejected correction collapses selection before replaying typing in original layout");
|
||||||
finish();
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"", false);
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"GHBDTN F"))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"continuous typing submitted in a single batch without a pause after Space");
|
||||||
|
}
|
||||||
|
stage = 21;
|
||||||
|
break;
|
||||||
|
case 21:
|
||||||
|
{
|
||||||
|
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
||||||
|
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
|
||||||
|
return ks::layoutLanguage(layout) == L"uk";
|
||||||
|
});
|
||||||
|
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
|
||||||
|
app->settings.autoLanguages = {L"en", L"uk"};
|
||||||
|
app->settings.cycle = {app->layouts[en].id, uk->id};
|
||||||
|
app->settings.autoWords[L"uk"] = {L"робить"};
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"hj,bnm ", false);
|
||||||
|
record(L"hj,bnm ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
stage = 22;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 22:
|
||||||
|
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||||
|
stage = 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +503,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()));
|
||||||
@@ -353,9 +519,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);
|
||||||
@@ -365,10 +544,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",
|
||||||
@@ -393,7 +573,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
|
||||||
@@ -439,8 +658,11 @@ int main(int argc, char **argv)
|
|||||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
deadline = GetTickCount64() + 30000;
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||||
stage = 20;
|
stage = 20;
|
||||||
|
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||||
|
stage = 30;
|
||||||
SetTimer(host, 1, 100, nullptr);
|
SetTimer(host, 1, 100, nullptr);
|
||||||
app->execute();
|
app->execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,105 @@ int main()
|
|||||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||||
"new screenshot editor starts with the pencil tool");
|
"new screenshot editor starts with the pencil tool");
|
||||||
|
{
|
||||||
|
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||||
|
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||||
|
auto mouse=[&](UINT message,POINT p,bool shift=false) {
|
||||||
|
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||||
|
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||||
|
mouse(WM_MOUSEMOVE,{100,90},true);
|
||||||
|
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
|
||||||
|
"50 pixel deviation keeps the current axis");
|
||||||
|
mouse(WM_MOUSEMOVE,{100,91},true);
|
||||||
|
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
|
||||||
|
"51 pixel deviation turns vertically and preserves the horizontal segment");
|
||||||
|
mouse(WM_MOUSEMOVE,{70,140});
|
||||||
|
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
|
||||||
|
"releasing Shift resumes freehand in the same stroke");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||||
|
mouse(WM_MOUSEMOVE,{140,150},true);
|
||||||
|
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
|
||||||
|
"pressing Shift mid-stroke anchors at the last drawn point");
|
||||||
|
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||||
|
mouse(WM_MOUSEMOVE,{150,200},true);
|
||||||
|
mouse(WM_LBUTTONUP,{150,210},true);
|
||||||
|
SetKeyboardState(savedKeys);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
|
||||||
|
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||||
|
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx,120},true);
|
||||||
|
for(int step=2;step<=90;++step)
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
|
||||||
|
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
|
||||||
|
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
|
||||||
|
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
|
||||||
|
for(int step=1;step<=90;++step)
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
|
||||||
|
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
|
||||||
|
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
|
||||||
|
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
|
||||||
|
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
|
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||||
|
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
|
||||||
|
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
|
||||||
|
"Shift snaps to exact 45 degree diagonals in all four directions");
|
||||||
|
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{100,40},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160,100},true);
|
||||||
|
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
|
||||||
|
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
|
||||||
|
"large deviation turns a horizontal segment diagonally without losing its corner");
|
||||||
|
mouse(WM_MOUSEMOVE,{200,100},true);
|
||||||
|
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
|
||||||
|
"small perpendicular deviation keeps the diagonal axis");
|
||||||
|
mouse(WM_MOUSEMOVE,{260,120},true);
|
||||||
|
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
|
||||||
|
"diagonal deviation beyond 50 pixels turns back to horizontal");
|
||||||
|
mouse(WM_LBUTTONUP,{260,120},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||||
|
POINT start{sx>0?310L:9L,120};
|
||||||
|
mouse(WM_LBUTTONDOWN,start,true);
|
||||||
|
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
|
||||||
|
auto end=c.stroke.to;
|
||||||
|
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
|
||||||
|
std::abs(end.x-start.x)==std::abs(end.y-start.y),
|
||||||
|
"diagonal stays at 45 degrees and inside image boundaries");
|
||||||
|
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
|
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,40});
|
||||||
|
mouse(WM_MOUSEMOVE,{100,70});
|
||||||
|
mouse(WM_LBUTTONUP,{120,90});
|
||||||
|
SetKeyboardState(savedKeys);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
|
||||||
|
c.marks[0].to.x==120 && c.marks[0].to.y==90,
|
||||||
|
"Ctrl keeps its existing drawing behavior without constraining the pencil");
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
for(int size:{32,48,64,96}) {
|
for(int size:{32,48,64,96}) {
|
||||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||||
ICONINFO info{};
|
ICONINFO info{};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#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>
|
||||||
@@ -44,6 +45,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") ==
|
||||||
@@ -247,6 +276,14 @@ int main(int argc, char **argv)
|
|||||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||||
options.cycle = {L"en", L"uk", L"ru"};
|
options.cycle = {L"en", L"uk", L"ru"};
|
||||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||||
|
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||||
|
{
|
||||||
|
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||||
|
require(shared && shared->text == expected && shared->target == 2,
|
||||||
|
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
|
||||||
|
}
|
||||||
|
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
|
||||||
|
"different dictionary results remain ambiguous");
|
||||||
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
||||||
require(release && release->text == L"реліз " && release->target == 2,
|
require(release && release->text == L"реліз " && release->target == 2,
|
||||||
"supplemental Ukrainian release word corrects from Russian layout");
|
"supplemental Ukrainian release word corrects from Russian layout");
|
||||||
@@ -268,6 +305,15 @@ int main(int argc, char **argv)
|
|||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
"never-correct list takes precedence over personal word");
|
"never-correct list takes precedence over personal word");
|
||||||
options.autoNever.clear();
|
options.autoNever.clear();
|
||||||
|
options.autoWords[L"uk"].push_back(L"робить");
|
||||||
|
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
||||||
|
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
||||||
|
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
||||||
|
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
||||||
|
"punctuation-key correction still rejects digits");
|
||||||
|
options.autoMinLength = 1;
|
||||||
|
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
||||||
|
"known English ye remains protected at minimum length one");
|
||||||
options.cycle = {L"en", L"ru"};
|
options.cycle = {L"en", L"ru"};
|
||||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
"personal dictionary respects disabled target layouts");
|
"personal dictionary respects disabled target layouts");
|
||||||
@@ -284,6 +330,12 @@ int main(int argc, char **argv)
|
|||||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||||
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
||||||
|
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
|
||||||
|
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
|
||||||
|
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
|
||||||
|
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
|
||||||
|
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
|
||||||
|
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
|
||||||
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
||||||
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ try {
|
|||||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
||||||
|
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
|
||||||
|
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
|
||||||
|
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
|
||||||
|
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
|
||||||
|
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
|
||||||
|
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
|
||||||
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
||||||
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
||||||
@@ -24,6 +30,8 @@ try {
|
|||||||
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
||||||
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
||||||
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
||||||
|
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
|
||||||
|
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
|
||||||
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
||||||
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
||||||
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
||||||
|
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
|
||||||
|
Start-UpdateProgress
|
||||||
|
try {
|
||||||
|
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
|
||||||
|
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
|
||||||
|
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
|
||||||
|
if ($Network) {
|
||||||
|
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
|
||||||
|
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
|
||||||
|
}
|
||||||
|
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
|
||||||
|
Show-UpdateProgress $stage 'Update stage under test'
|
||||||
|
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
|
||||||
|
}
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
|
||||||
|
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
|
||||||
|
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
|
||||||
|
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
|
||||||
|
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
|
||||||
|
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
|
||||||
|
'Passed updater progress UI checks'
|
||||||
|
} finally {
|
||||||
|
$script:progressWindow.Dispose()
|
||||||
|
$script:progressWindow = $null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user