Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 |
@@ -0,0 +1,35 @@
|
||||
# Project instructions
|
||||
|
||||
## Mandatory Wiki updates for every release
|
||||
|
||||
A release is not complete until the published project Wiki reflects that release.
|
||||
Apply this rule to every release, including patch releases and bug-fix-only releases.
|
||||
|
||||
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
|
||||
|
||||
1. Confirm the release version from `CMakeLists.txt`, the release tag and
|
||||
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
|
||||
preserve the release channel rules in `README.md`.
|
||||
2. Update the latest-version information on the Wiki home pages and any other
|
||||
pages identifying the current release. Update release dates, download links
|
||||
and upgrade instructions where needed. Keep historical version references
|
||||
and compatibility bridge instructions when they are still accurate.
|
||||
3. Review the changes since the last release documented in the Wiki, not just
|
||||
the immediately preceding release. Bring the relevant feature pages up to
|
||||
date with the shipped behavior: new, changed and removed functionality,
|
||||
settings, shortcuts, defaults, limitations and troubleshooting. Update
|
||||
screenshots or examples when they no longer match the application.
|
||||
For bug-fix-only releases, check whether documented behavior or workarounds
|
||||
need correction; do not invent feature changes.
|
||||
4. Keep English and Ukrainian pages synchronized, including `Home` and
|
||||
`Home-uk`, and preserve working translation and navigation links.
|
||||
5. Publish the Wiki changes as part of the release workflow. Read back the
|
||||
published pages and verify the current version, feature descriptions,
|
||||
download links and translation links. A local Wiki edit alone is not enough.
|
||||
6. In the release completion report, link the updated Wiki pages and summarize
|
||||
the documentation changes. If Wiki publication or verification is blocked,
|
||||
explicitly report the outstanding step and do not claim the release workflow
|
||||
is complete.
|
||||
|
||||
Updating only the release notes or changing only the Wiki version number does
|
||||
not satisfy this rule; the current functionality must also be reviewed.
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.12 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.22 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -17,6 +17,8 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
||||
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
@@ -39,7 +41,7 @@ endif()
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||
@@ -49,12 +51,14 @@ else()
|
||||
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
enable_testing()
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
|
||||
@@ -160,7 +160,11 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin
|
||||
for shapes.
|
||||
**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
|
||||
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
|
||||
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.
|
||||
@@ -216,15 +220,27 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
## Implemented
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
||||
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||
history and cloud synchronization. Paste is not blindly retried.
|
||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||
- 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.
|
||||
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
|
||||
and no Enter action is sent.
|
||||
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||
- 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.
|
||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||
@@ -266,6 +282,12 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
||||
|
||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||
in the context menu opens the original image in the built-in screenshot editor.
|
||||
Annotate it there, then copy the result. Opening the editor leaves the
|
||||
clipboard and original history entry unchanged. Text items still open as text;
|
||||
Enter and double-click still paste the selected item.
|
||||
|
||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||
|
||||
## Data and migration
|
||||
@@ -275,27 +297,38 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||
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.
|
||||
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
||||
Only failures flush this context to `kswitcher.log`; flushed
|
||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
||||
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
|
||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||
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,
|
||||
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
|
||||
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
|
||||
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
|
||||
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
||||
An unchanged result containing no letters, or a direct command
|
||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
||||
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
||||
checks the focused editor without moving the caret, changing selection, or copying again.
|
||||
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.
|
||||
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,
|
||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
||||
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.
|
||||
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||
|
||||
@@ -447,6 +480,13 @@ against a staged build. It briefly opens real selection/editor windows without c
|
||||
|
||||
### 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
|
||||
`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
|
||||
|
||||
Binary file not shown.
+9
-8
@@ -1,19 +1,20 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.12 — 2026-10-30
|
||||
## v0.6.22 — 2026-10-31
|
||||
|
||||
## Changes
|
||||
- Start automatic layout correction immediately after Space and preserve subsequent typing while the correction runs.
|
||||
- Accept identical corrected words shared by multiple selected languages; use the selected language order to choose the target layout. Different valid spellings remain ambiguous.
|
||||
- Run the log viewer in the configurator process, separate from the resident input hooks. Clearing logs remains coordinated with the resident process.
|
||||
- Keep the native four-column ListView. Resize the table with its window, anchor the footer and Close button, and make Details fill the remaining width.
|
||||
- Exclude copied diagnostic rows from clipboard history.
|
||||
- Recover partial selections during recent-text conversion, repeat conversion and undo: when copying returns a verified suffix of the expected text, extend the selection with paced Shift+Left presses and copy again. Replacement still requires a complete verified match.
|
||||
- Recent-text suffix checks map physical keys through convertible layouts, including Cyrillic and mixed case. This covers reported suffixes such as `Switcher ` from `PuntoSwitcher `, `зштп ` from the layout equivalent of `snipping `, and `лувШт ` from `LinkedIn `.
|
||||
- Diagnostic context records the expected previous result and selection-recovery stages. Unrelated or empty copied ranges are not extended; input/context changes still cancel replacement.
|
||||
|
||||
## Validation
|
||||
Native and input regression suites, screenshot UI and updater checks, log-viewer integration, continuous-typing integration, and isolated configurator/log process checks. Manual development-build checks covered window dragging and automatic Details column width.
|
||||
419 native regressions, 473 input/UI checks, screenshot editor UI, notification registration, 48 updater checks and updater progress checks passed, including Cyrillic and mixed-case suffix verification. The isolated editor integration test passed partial-selection recovery, the `PuntoSwitcher ` repeat-conversion case, clipboard restoration and rejection of unrelated text. Direct validation in LinkedIn and ChatGPT is still outstanding; native-editor tests do not establish browser-editor compatibility.
|
||||
|
||||
## Limitations
|
||||
Recovery is attempted once and still depends on the editor processing simulated selection keys. A failed full-text check cancels replacement. Existing clipboard, accessibility and input-context restrictions remain; this patch does not change paste readback verification.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
866de2d1d6fe121a7845760519f934bf0bd7820bbd11734a14c454b69a6bb8ad KSwitcher-win-x64.zip
|
||||
b80632cc93abadf2a23921c3275b8ff37d98c92b8b583a9621d0c2b589ed6a35 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -10,6 +10,8 @@ $outDir = Join-Path $root 'build'
|
||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
||||
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||
Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
@@ -39,6 +41,10 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||
& .\build\KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,12,0
|
||||
PRODUCTVERSION 0,6,12,0
|
||||
FILEVERSION 0,6,22,0
|
||||
PRODUCTVERSION 0,6,22,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.12"
|
||||
VALUE "FileVersion", "0.6.22"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+181
-45
@@ -2,6 +2,8 @@
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "configuration.h"
|
||||
#include "diagnostics.h"
|
||||
#include "paste_probe.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
@@ -12,6 +14,17 @@ namespace ks
|
||||
App *App::current = nullptr;
|
||||
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>> out{{Toggle, s.toggle},
|
||||
@@ -111,7 +124,13 @@ class Transaction
|
||||
{
|
||||
check();
|
||||
adopted.clear();
|
||||
app.ownSequence = expected = writeClipboard(app.window, c);
|
||||
// These are temporary transaction contents, not a user's clipboard entry.
|
||||
// In particular, do not invite the Windows history service to read our
|
||||
// sentinel while the target editor is trying to open the clipboard.
|
||||
auto temporary = c;
|
||||
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
|
||||
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
|
||||
app.ownSequence = expected = writeClipboard(app.window, temporary);
|
||||
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
||||
// Never adopt that writer's sequence as our own and paste its content.
|
||||
if (GetClipboardOwner() != app.window)
|
||||
@@ -133,6 +152,8 @@ class Transaction
|
||||
return {};
|
||||
}
|
||||
ULONGLONG deadline = GetTickCount64() + 2000;
|
||||
ULONGLONG retryAt = GetTickCount64() + 250;
|
||||
unsigned attempts = 1;
|
||||
DWORD changed = before;
|
||||
ULONGLONG stable = 0;
|
||||
bool changedAgain = false;
|
||||
@@ -149,6 +170,21 @@ class Transaction
|
||||
}
|
||||
if (changed != before && GetTickCount64() - stable >= 60)
|
||||
break;
|
||||
// Native editors can silently drop Ctrl+C if another process has
|
||||
// the clipboard open. Retry only while our sentinel is untouched;
|
||||
// never overwrite a copy result or another application's content.
|
||||
if (changed == before && attempts < 3 && GetTickCount64() >= retryAt &&
|
||||
!GetOpenClipboardWindow())
|
||||
{
|
||||
check();
|
||||
if (!valid() || GetClipboardSequenceNumber() != before)
|
||||
return {};
|
||||
if (!chord(L'C', MOD_CONTROL))
|
||||
return {};
|
||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
|
||||
++attempts;
|
||||
retryAt = GetTickCount64() + 250;
|
||||
}
|
||||
Sleep(10);
|
||||
}
|
||||
if (changed == before || !valid())
|
||||
@@ -191,7 +227,8 @@ class Transaction
|
||||
return selectPrevious(n, [&] { return valid(); });
|
||||
}
|
||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||
const std::function<bool(const std::wstring &)> &matches)
|
||||
const std::function<bool(const std::wstring &)> &matches,
|
||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||
{
|
||||
// WebView word boundaries are only candidates; exact verification is mandatory.
|
||||
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
||||
@@ -211,7 +248,25 @@ class Transaction
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
return select(count) ? copy() : std::wstring{};
|
||||
auto text = select(count) ? copy() : std::wstring{};
|
||||
// Chromium editors can apply only part of a burst of Shift+Left events.
|
||||
// Extend only a verified suffix; never replace a partial or unrelated range.
|
||||
if (valid() && !matches(text) && !text.empty() &&
|
||||
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
|
||||
caretSteps(text) < count)
|
||||
{
|
||||
const auto missing = count - caretSteps(text);
|
||||
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
|
||||
for (size_t i = 0; i < missing; ++i)
|
||||
{
|
||||
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
||||
return {};
|
||||
Sleep(25);
|
||||
}
|
||||
text = copy();
|
||||
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
bool paste(const Clip &clip)
|
||||
{
|
||||
@@ -397,7 +452,7 @@ void App::addTray()
|
||||
icon.uCallbackMessage = TrayMessage;
|
||||
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||
wcscpy_s(icon.szTip, L"KSwitcher — keyboard layouts & clipboard");
|
||||
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
||||
Shell_NotifyIconW(NIM_ADD, &icon);
|
||||
icon.uVersion = NOTIFYICON_VERSION_4;
|
||||
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
||||
@@ -566,7 +621,8 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
return;
|
||||
}
|
||||
run.record(ch, fg, layout, GetTickCount64());
|
||||
if (settings.autoCorrect && mods == 0 && 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)
|
||||
{
|
||||
for (int source = 0; source < (int)layouts.size(); ++source)
|
||||
@@ -619,7 +675,18 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
for (auto &trigger : variants)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -693,12 +760,13 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
return true;
|
||||
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);
|
||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||
return false;
|
||||
typingTarget = target;
|
||||
snippetTyping = snippet;
|
||||
return true;
|
||||
}
|
||||
void App::replayTyping()
|
||||
@@ -710,7 +778,12 @@ void App::replayTyping()
|
||||
target = typingTarget;
|
||||
typingTarget = nullptr;
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
inputs.swap(deferredTyping);
|
||||
if (snippetTyping)
|
||||
for (auto &input : inputs)
|
||||
input.ki.dwExtraInfo = snippetReplayMarker();
|
||||
snippetTyping = false;
|
||||
}
|
||||
busy = false;
|
||||
if (inputs.empty())
|
||||
@@ -826,14 +899,30 @@ void App::transform(int command, int snippet)
|
||||
L" version=" + std::to_wstring(currentVersion) +
|
||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||
}
|
||||
const bool preDeferred = command == AutoCorrect && autoPending;
|
||||
const bool preDeferred = (command == AutoCorrect && autoPending) ||
|
||||
(snippet >= 0 && snippetPending == snippet);
|
||||
if (busy.exchange(true) && !preDeferred)
|
||||
{
|
||||
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;
|
||||
}
|
||||
if (preDeferred) autoPending = false;
|
||||
if (preDeferred)
|
||||
{
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
}
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -865,7 +954,7 @@ void App::transform(int command, int snippet)
|
||||
} reset{busy, window, preDeferred};
|
||||
auto start = GetTickCount64();
|
||||
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;
|
||||
if (manual)
|
||||
{
|
||||
@@ -890,21 +979,34 @@ void App::transform(int command, int snippet)
|
||||
bool enabled;
|
||||
std::wstring &reason;
|
||||
std::wstring &text, &result, &expected;
|
||||
std::wstring &actual, &verification;
|
||||
int &hint, &to;
|
||||
int command;
|
||||
const std::vector<Layout> &layouts;
|
||||
~Audit()
|
||||
{
|
||||
if (enabled)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
|
||||
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
|
||||
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
|
||||
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
|
||||
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
|
||||
L" result=" + diagnosticText(result),
|
||||
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
||||
reason != L"unchanged-expected");
|
||||
try
|
||||
{
|
||||
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
|
||||
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
|
||||
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
|
||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
|
||||
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
||||
{"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
|
||||
{
|
||||
auto valid = [&]
|
||||
@@ -940,14 +1042,15 @@ void App::transform(int command, int snippet)
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : recent.keys)
|
||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||
expected = predicted;
|
||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||
!autoCorrectionAllowed(target, config) || !valid())
|
||||
return;
|
||||
}
|
||||
reason = L"clipboard-snapshot";
|
||||
if (command == AutoCorrect)
|
||||
if (command == AutoCorrect || snippet >= 0)
|
||||
{
|
||||
if (!beginTypingDeferral(target, version))
|
||||
if (!beginTypingDeferral(target, version, snippet >= 0))
|
||||
return;
|
||||
reset.deferred = true;
|
||||
}
|
||||
@@ -979,9 +1082,12 @@ void App::transform(int command, int snippet)
|
||||
autoSelected = true;
|
||||
Sleep(25);
|
||||
text = tx.copy();
|
||||
// Some editors first select only ") ". Extend by whole-word
|
||||
// boundaries, then still verify against the complete tracked run.
|
||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
||||
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||
// suffix of the tracked input, and still require a complete match.
|
||||
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))
|
||||
break;
|
||||
@@ -1039,13 +1145,11 @@ void App::transform(int command, int snippet)
|
||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||
if (!tx.select(sn.trigger.size()))
|
||||
return;
|
||||
autoSelected = true;
|
||||
reason = L"snippet-verification-mismatch";
|
||||
text = tx.copy();
|
||||
if (tail.match(text, available, physicalLayout()) < 0)
|
||||
{
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
result = parseTemplate(sn.text).text;
|
||||
automatic = true;
|
||||
}
|
||||
@@ -1053,7 +1157,9 @@ void App::transform(int command, int snippet)
|
||||
{
|
||||
text =
|
||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
|
||||
[&](const std::wstring &candidate)
|
||||
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
||||
hint = recent.match(text, available, physicalLayout());
|
||||
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
@@ -1070,6 +1176,7 @@ void App::transform(int command, int snippet)
|
||||
previous->version == version)
|
||||
{
|
||||
expected = previous->result;
|
||||
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
|
||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||
[&](const std::wstring &candidate)
|
||||
{ return newlines(candidate) == newlines(previous->result); });
|
||||
@@ -1117,16 +1224,13 @@ void App::transform(int command, int snippet)
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
}
|
||||
int source = detect(text, available, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it;
|
||||
to = nextConversionLayout(text, available, cycle, hint);
|
||||
}
|
||||
result = convertTo(text, available, to, hint);
|
||||
}
|
||||
}
|
||||
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) +
|
||||
L" changed=" + std::to_wstring(result != text));
|
||||
if (result != text)
|
||||
@@ -1137,10 +1241,27 @@ void App::transform(int command, int snippet)
|
||||
autoSelected = false;
|
||||
reason = result == text ? L"conversion-unchanged" :
|
||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||
if (result == text &&
|
||||
(std::none_of(text.begin(), text.end(), letter) ||
|
||||
(command >= Direct && detect(text, available, hint) == to)))
|
||||
reason = L"unchanged-expected";
|
||||
if (manual && result == text)
|
||||
{
|
||||
reason = L"Conversion unchanged";
|
||||
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())
|
||||
reason = L"context-changed-after-paste";
|
||||
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||
@@ -1158,12 +1279,22 @@ void App::transform(int command, int snippet)
|
||||
if (snippet >= 0 && sent && tx.valid())
|
||||
{
|
||||
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();
|
||||
if (tx.valid())
|
||||
chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT);
|
||||
if (!tx.valid()) return;
|
||||
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))
|
||||
{
|
||||
@@ -1513,15 +1644,20 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case AutoCorrectMessage:
|
||||
if (app->autoPending)
|
||||
{
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||
app->transform(AutoCorrect);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
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);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
case HistoryMessage:
|
||||
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
||||
|
||||
@@ -109,8 +109,10 @@ class App
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
int snippetPending = -1;
|
||||
bool snippetTyping{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t);
|
||||
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||
void replayTyping();
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
@@ -118,6 +120,7 @@ class App
|
||||
};
|
||||
void showHistory(App &, bool visible = true);
|
||||
void launchScreenshot(App &);
|
||||
void launchScreenshotEditor(App &, const Clip &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
void showLogs(App &);
|
||||
|
||||
+5
-1
@@ -152,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
suffix.insert(suffix.begin(), word.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 {};
|
||||
for (auto &never : settings.autoNever)
|
||||
if (lower(never) == lower(word))
|
||||
|
||||
+39
-1
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
|
||||
}
|
||||
return best < 0 ? text : convert(text, layouts[best], to);
|
||||
}
|
||||
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const std::vector<int> &cycle, int hint)
|
||||
{
|
||||
if (cycle.empty())
|
||||
return -1;
|
||||
int source = detect(text, layouts, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||
// Compare every candidate with the original input, never an intermediate conversion.
|
||||
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||
{
|
||||
int target = cycle[(start + offset) % cycle.size()];
|
||||
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||
return target;
|
||||
}
|
||||
return cycle[start];
|
||||
}
|
||||
std::wstring cycleCase(const std::wstring &text)
|
||||
{
|
||||
if (text == upper(text))
|
||||
@@ -261,7 +281,17 @@ Template parseTemplate(const std::wstring &text)
|
||||
size_t cursor = std::wstring::npos;
|
||||
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}";
|
||||
i += 10;
|
||||
@@ -449,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
||||
keys.pop_back();
|
||||
at = now;
|
||||
}
|
||||
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||
return -1;
|
||||
Run tail = *this;
|
||||
tail.keys = keys.substr(keys.size() - text.size());
|
||||
return tail.match(text, layouts, physical);
|
||||
}
|
||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.size() != keys.size() || overflow)
|
||||
|
||||
@@ -24,6 +24,8 @@ std::wstring upper(std::wstring text);
|
||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||
const std::vector<int> &cycle, int hint);
|
||||
std::wstring cycleCase(const std::wstring &);
|
||||
std::wstring transliterate(const std::wstring &);
|
||||
struct Snippet
|
||||
@@ -34,6 +36,7 @@ struct Template
|
||||
{
|
||||
std::wstring text, suffix;
|
||||
int cursors = 0;
|
||||
int enters = 0;
|
||||
};
|
||||
Template parseTemplate(const std::wstring &);
|
||||
struct Hotkey
|
||||
@@ -91,5 +94,6 @@ struct Run
|
||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||
void backspace(HWND, ULONGLONG);
|
||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
};
|
||||
} // namespace ks
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include "../vendor/json.hpp"
|
||||
#include "platform.h"
|
||||
#include <sstream>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
using DiagnosticJson = nlohmann::json;
|
||||
struct OperationLogRow
|
||||
{
|
||||
std::wstring time, process, command, source, target, outcome, details;
|
||||
bool problem = true;
|
||||
};
|
||||
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
|
||||
{
|
||||
std::vector<OperationLogRow> rows;
|
||||
std::wistringstream stream(contents);
|
||||
std::wstring line;
|
||||
OperationLogRow *legacy = nullptr;
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
if (!line.empty() && line.back() == L'\r')
|
||||
line.pop_back();
|
||||
if (line.empty())
|
||||
continue;
|
||||
auto marker = line.find(L" Operation {");
|
||||
if (marker != line.npos)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
|
||||
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
|
||||
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
|
||||
field("source"), field("target"), field("outcome")};
|
||||
row.problem = j.value("problem", true);
|
||||
row.details =
|
||||
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
||||
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
||||
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
|
||||
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
||||
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
||||
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
||||
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
|
||||
L"\r\n\r\nContext and stages:\r\n" + field("context");
|
||||
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
|
||||
if (at == 0 || row.details[at - 1] != L'\r')
|
||||
row.details.insert(at++, 1, L'\r');
|
||||
rows.push_back(std::move(row));
|
||||
legacy = nullptr;
|
||||
continue;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
} // Preserve malformed and old records for diagnosis.
|
||||
}
|
||||
if (line.front() != L' ' || !legacy)
|
||||
{
|
||||
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
|
||||
legacy = &rows.back();
|
||||
}
|
||||
else
|
||||
legacy->details += L"\r\n" + line;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
|
||||
const std::wstring &expected)
|
||||
{
|
||||
if (actual == expected)
|
||||
return L"Paste verified";
|
||||
if (actual == original)
|
||||
return L"Original text remained";
|
||||
return L"Readback differs";
|
||||
}
|
||||
} // namespace ks
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace ks {
|
||||
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||
// visible preview to admit another visible preview: that creates a paint loop.
|
||||
template<class T>
|
||||
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||
const std::set<std::wstring> &visible, size_t limit=32)
|
||||
{
|
||||
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
||||
if(!visible.contains(it->first)) it=cache.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
#include <uiautomation.h>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
// Read-only UI Automation: never select, move the caret, or touch the clipboard.
|
||||
class PasteProbe
|
||||
{
|
||||
IUIAutomation2 *automation{};
|
||||
IUIAutomationElement *element{};
|
||||
IUIAutomationTextPattern *pattern{};
|
||||
|
||||
public:
|
||||
std::wstring status = L"not-requested";
|
||||
PasteProbe(bool enabled, const std::wstring &original)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
status = L"accessibility-unavailable";
|
||||
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
|
||||
(void **)&automation)))
|
||||
return;
|
||||
automation->put_ConnectionTimeout(200);
|
||||
automation->put_TransactionTimeout(200);
|
||||
if (FAILED(automation->GetFocusedElement(&element)) || !element)
|
||||
return;
|
||||
BOOL password = TRUE;
|
||||
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
|
||||
{
|
||||
status = L"protected-or-inaccessible-field";
|
||||
return;
|
||||
}
|
||||
if (FAILED(element->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern,
|
||||
(void **)&pattern)) ||
|
||||
!pattern)
|
||||
return;
|
||||
auto selected = read(1, false);
|
||||
auto normalize = [](std::wstring value)
|
||||
{
|
||||
for (size_t at = 0; (at = value.find(L"\r\n", at)) != value.npos;)
|
||||
value.erase(at, 1);
|
||||
return value;
|
||||
};
|
||||
if (!selected || normalize(*selected) != normalize(original))
|
||||
{
|
||||
if (selected)
|
||||
status = L"initial-selection-mismatch";
|
||||
if (pattern)
|
||||
pattern->Release();
|
||||
pattern = nullptr;
|
||||
return;
|
||||
}
|
||||
status = L"ready";
|
||||
}
|
||||
PasteProbe(const PasteProbe &) = delete;
|
||||
~PasteProbe()
|
||||
{
|
||||
if (pattern)
|
||||
pattern->Release();
|
||||
if (element)
|
||||
element->Release();
|
||||
if (automation)
|
||||
automation->Release();
|
||||
}
|
||||
std::optional<std::wstring> read(int characters, bool allowCaret = true)
|
||||
{
|
||||
if (!pattern)
|
||||
return {};
|
||||
if (characters <= 0 || characters > 4096)
|
||||
{
|
||||
status = L"readback-size-limit";
|
||||
return {};
|
||||
}
|
||||
IUIAutomationElement *focused{};
|
||||
BOOL same = FALSE;
|
||||
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
|
||||
{
|
||||
automation->CompareElements(element, focused, &same);
|
||||
}
|
||||
if (!same)
|
||||
{
|
||||
if (focused)
|
||||
focused->Release();
|
||||
status = L"focused-control-changed";
|
||||
return {};
|
||||
}
|
||||
BOOL password = TRUE;
|
||||
if (FAILED(focused->get_CurrentIsPassword(&password)) || password)
|
||||
{
|
||||
focused->Release();
|
||||
status = L"protected-or-inaccessible-field";
|
||||
return {};
|
||||
}
|
||||
// Native Edit accessibility proxies can retain a text snapshot in the
|
||||
// pattern object. Obtain a fresh pattern after paste, not the pre-paste one.
|
||||
pattern->Release();
|
||||
pattern = nullptr;
|
||||
HRESULT refreshed =
|
||||
focused->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern, (void **)&pattern);
|
||||
focused->Release();
|
||||
if (FAILED(refreshed) || !pattern)
|
||||
{
|
||||
status = L"accessibility-unavailable";
|
||||
return {};
|
||||
}
|
||||
IUIAutomationTextRangeArray *ranges{};
|
||||
if (FAILED(pattern->GetSelection(&ranges)) || !ranges)
|
||||
{
|
||||
status = L"selection-unavailable";
|
||||
return {};
|
||||
}
|
||||
int count{};
|
||||
ranges->get_Length(&count);
|
||||
IUIAutomationTextRange *range{};
|
||||
if (count == 1)
|
||||
ranges->GetElement(0, &range);
|
||||
ranges->Release();
|
||||
if (!range)
|
||||
{
|
||||
status = L"selection-ambiguous";
|
||||
return {};
|
||||
}
|
||||
int difference{};
|
||||
HRESULT hr = range->CompareEndpoints(TextPatternRangeEndpoint_Start, range,
|
||||
TextPatternRangeEndpoint_End, &difference);
|
||||
if (SUCCEEDED(hr) && difference == 0)
|
||||
{
|
||||
if (!allowCaret)
|
||||
{
|
||||
range->Release();
|
||||
status = L"initial-selection-unavailable";
|
||||
return {};
|
||||
}
|
||||
int moved{};
|
||||
hr = range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start, TextUnit_Character, -characters,
|
||||
&moved);
|
||||
if (SUCCEEDED(hr) && moved != -characters)
|
||||
hr = E_FAIL;
|
||||
}
|
||||
BSTR value{};
|
||||
if (SUCCEEDED(hr))
|
||||
hr = range->GetText(8193, &value);
|
||||
range->Release();
|
||||
if (FAILED(hr))
|
||||
{
|
||||
SysFreeString(value);
|
||||
status = L"range-read-failed";
|
||||
return {};
|
||||
}
|
||||
std::wstring text(value ? value : L"", SysStringLen(value));
|
||||
SysFreeString(value);
|
||||
if (text.size() > 8192)
|
||||
{
|
||||
status = L"readback-size-limit";
|
||||
return {};
|
||||
}
|
||||
status = L"read-back-via-accessibility";
|
||||
return text;
|
||||
}
|
||||
};
|
||||
} // namespace ks
|
||||
+21
-2
@@ -1,6 +1,8 @@
|
||||
#include "platform.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <imm.h>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
@@ -624,6 +626,22 @@ void diagnosticEvent(const std::wstring &message, bool problem)
|
||||
if (!incident.empty())
|
||||
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)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
@@ -645,8 +663,9 @@ void log(const std::wstring &message)
|
||||
std::ofstream f(path, std::ios::app);
|
||||
SYSTEMTIME t;
|
||||
GetLocalTime(&t);
|
||||
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
|
||||
<< t.wSecond << ' ' << utf8(message) << '\n';
|
||||
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
|
||||
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
|
||||
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline std::wstring localBuildTitle(const std::wstring &title)
|
||||
{
|
||||
static const bool development=[] {
|
||||
wchar_t path[32768]{};
|
||||
if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false;
|
||||
std::error_code error;
|
||||
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
|
||||
}();
|
||||
return development?title+L" [LOCAL DEV]":title;
|
||||
}
|
||||
using Bytes = std::vector<unsigned char>;
|
||||
struct Format
|
||||
{
|
||||
@@ -66,6 +76,7 @@ class DiagnosticBuffer
|
||||
};
|
||||
void setLoggingEnabled(bool enabled);
|
||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||
void diagnosticOperation(const std::string &json);
|
||||
std::wstring readDiagnosticLog(bool previous);
|
||||
void clearDiagnosticLog(bool previous);
|
||||
} // namespace ks
|
||||
|
||||
+76
-3
@@ -178,6 +178,11 @@ struct Capture
|
||||
COLORREF currentColor=RGB(225,42,50);
|
||||
COLORREF customColors[16]{};
|
||||
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{};
|
||||
RECT selection{}, view{};
|
||||
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.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.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 text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||
if(text!=c.statusText) {
|
||||
@@ -836,12 +841,12 @@ void edit(HWND w, Capture &c)
|
||||
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
|
||||
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
|
||||
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
|
||||
SetWindowTextW(w,title.c_str());
|
||||
SetWindowTextW(w,localBuildTitle(title).c_str());
|
||||
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
|
||||
InitCommonControlsEx(&statusControls);
|
||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||
const wchar_t *labels[]{L"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)"};
|
||||
static_assert(std::size(labels)==std::size(ToolIds));
|
||||
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;
|
||||
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(!c->editing) DestroyWindow(w); // Cancel region selection only.
|
||||
return 0;
|
||||
@@ -1100,6 +1112,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_KEYUP:
|
||||
if(wp==VK_SHIFT) c->strokeConstrained=false;
|
||||
return 0;
|
||||
case WM_LBUTTONDBLCLK:
|
||||
if(c->editing && !c->dragging) {
|
||||
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.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
|
||||
c->stroke.points.push_back(p);
|
||||
c->strokeConstrained=false;
|
||||
}
|
||||
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)
|
||||
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) {
|
||||
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();
|
||||
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
|
||||
c->stroke.to=c->end;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#include "app.h"
|
||||
#include "screenshot_notifications.h"
|
||||
|
||||
namespace ks {
|
||||
void launchScreenshot(App &app)
|
||||
static void launch(App &app, const Clip *clip)
|
||||
{
|
||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||
auto command=L"\""+path.wstring()+L"\" ";
|
||||
if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip);
|
||||
else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||
if(app.settings.writeLog) command+=L" --write-log";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
}
|
||||
void launchScreenshot(App &app) { launch(app,nullptr); }
|
||||
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
|
||||
}
|
||||
|
||||
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
|
||||
return result;
|
||||
}
|
||||
}
|
||||
std::wstring cacheScreenshotForEditor(const Clip &clip)
|
||||
{
|
||||
// Normalize PNG/DIBV5 and other supported clipboard images at full resolution.
|
||||
auto pixels=decodeImage(clip);
|
||||
if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER))
|
||||
throw std::runtime_error("Image is too large for the screenshot editor");
|
||||
auto dib=imageDib(pixels);
|
||||
auto directory=cacheDirectory(); cleanCache(directory);
|
||||
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||
std::wstring id(value+1,36);
|
||||
atomicWrite(directory/(id+L".capture"),protect(dib.data));
|
||||
return id;
|
||||
}
|
||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||
{
|
||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||
|
||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
||||
DWORD startScreenshotActivationListener();
|
||||
bool validScreenshotToken(const std::wstring &);
|
||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||
}
|
||||
|
||||
+125
-268
@@ -1,9 +1,12 @@
|
||||
#include "../resources/resource.h"
|
||||
#include "app.h"
|
||||
#include "history_preview.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "configuration.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <commdlg.h>
|
||||
@@ -34,7 +37,8 @@ struct WindowLayout
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||
HWND stretch{}, bottomLeft{}, bottomRight{};
|
||||
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
||||
bool autoOptions{};
|
||||
};
|
||||
WindowLayout *windowLayoutState(HWND w)
|
||||
{
|
||||
@@ -70,7 +74,7 @@ void arrangeWindow(HWND w)
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
RECT client{}; GetClientRect(w, &client);
|
||||
bool expandable = layout->history || layout->stretch;
|
||||
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
struct Move { HWND child; RECT rect; };
|
||||
@@ -87,8 +91,35 @@ void arrangeWindow(HWND w)
|
||||
next.right += extraWidth;
|
||||
next.bottom += extraHeight;
|
||||
}
|
||||
if (child == layout->details)
|
||||
{
|
||||
next.right += extraWidth;
|
||||
OffsetRect(&next, 0, extraHeight);
|
||||
}
|
||||
if (child == layout->bottomLeft || child == layout->bottomRight)
|
||||
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
||||
if (layout->autoOptions)
|
||||
{
|
||||
if (r.top >= 396) OffsetRect(&next, 0, extraHeight);
|
||||
if (r.top == 302 || r.top == 328 || (id >= 401 && id <= 403))
|
||||
{
|
||||
int column = id >= 401 && id <= 403 ? id - 401 : (r.left - 18) / 178;
|
||||
int leftExtra = extraWidth * column / 3;
|
||||
OffsetRect(&next, leftExtra, 0);
|
||||
next.right += extraWidth * (column + 1) / 3 - leftExtra;
|
||||
if (r.top == 328) next.bottom += extraHeight;
|
||||
}
|
||||
else if (r.top == 474 || r.top == 500)
|
||||
{
|
||||
bool rightColumn = r.left >= 290;
|
||||
OffsetRect(&next, rightColumn ? extraWidth / 2 : 0, 0);
|
||||
next.right += rightColumn ? extraWidth - extraWidth / 2 : extraWidth / 2;
|
||||
}
|
||||
else if (id == 406 || id == 410 || id == IDOK || id == IDCANCEL)
|
||||
OffsetRect(&next, extraWidth, 0);
|
||||
else if (r.right - r.left >= 500)
|
||||
next.right += extraWidth;
|
||||
}
|
||||
RECT current{}; GetWindowRect(child, ¤t);
|
||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
||||
@@ -209,6 +240,9 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
|
||||
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
|
||||
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
|
||||
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
|
||||
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
||||
{
|
||||
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
||||
@@ -288,7 +322,7 @@ std::wstring value(HWND w, int id)
|
||||
text.resize(n);
|
||||
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());
|
||||
}
|
||||
@@ -339,11 +373,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
||||
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
||||
SetWindowSubclass(w, layoutProc, 2, 0);
|
||||
if(windowLayoutState(w)->scrolling) enableWheelBatching(w);
|
||||
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
||||
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
arrangeWindow(w);
|
||||
SetWindowTextW(w, title);
|
||||
SetWindowTextW(w, localBuildTitle(title).c_str());
|
||||
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
||||
GetSystemMetricsForDpi(SM_CYSMICON, dpi), LR_SHARED);
|
||||
@@ -449,7 +484,17 @@ struct HistoryView
|
||||
std::map<std::wstring, std::string> richTexts;
|
||||
HWND rich{};
|
||||
std::set<std::wstring> pending;
|
||||
bool scrolling{};
|
||||
};
|
||||
bool selectedImage(HWND w, HistoryView *v)
|
||||
{
|
||||
int i=(int)SendDlgItemMessageW(w,102,LB_GETCURSEL,0,0);
|
||||
return i>=0 && i<(int)v->items.size() && v->items[i].image;
|
||||
}
|
||||
void updateHistoryActions(HWND w, HistoryView *v)
|
||||
{
|
||||
SetDlgItemTextW(w,114,selectedImage(w,v)?L"Edit image":L"Open item");
|
||||
}
|
||||
std::wstring selected(HWND w, HistoryView *v)
|
||||
{
|
||||
int i = (int)SendDlgItemMessageW(w, 102, LB_GETCURSEL, 0, 0);
|
||||
@@ -478,6 +523,7 @@ void search(HWND w, HistoryView *v)
|
||||
}
|
||||
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
||||
{
|
||||
if(v->scrolling) return;
|
||||
const auto id = entry.id;
|
||||
const bool image = entry.image;
|
||||
if (v->thumbnails.contains(id) || v->richTexts.contains(id) || !v->pending.insert(id).second)
|
||||
@@ -661,6 +707,12 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
switch (msg)
|
||||
{
|
||||
case ScrollActivity:
|
||||
if((HWND)wp==GetDlgItem(w,102)) {
|
||||
v->scrolling=lp!=0;
|
||||
if(!v->scrolling) InvalidateRect((HWND)wp,nullptr,FALSE);
|
||||
}
|
||||
return 0;
|
||||
case WM_COMMAND:
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
@@ -673,6 +725,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == 102 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
{
|
||||
updateHistoryActions(w,v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
|
||||
@@ -718,10 +771,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (result->generation != v->generation)
|
||||
return 0;
|
||||
auto old = selected(w, v);
|
||||
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
||||
v->items = std::move(result->items);
|
||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
||||
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
||||
int select = 0;
|
||||
int restoreTop=0;
|
||||
for (size_t i = 0; i < v->items.size(); ++i)
|
||||
{
|
||||
auto &e = v->items[i];
|
||||
@@ -729,8 +785,11 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
SendDlgItemMessageW(w, 102, LB_ADDSTRING, 0, (LPARAM)text.c_str());
|
||||
if (e.id == old)
|
||||
select = (int)i;
|
||||
if(e.id==topId) restoreTop=(int)i;
|
||||
}
|
||||
SendDlgItemMessageW(w, 102, LB_SETCURSEL, select, 0);
|
||||
SendDlgItemMessageW(w,102,LB_SETTOPINDEX,restoreTop,0);
|
||||
updateHistoryActions(w,v);
|
||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(GetDlgItem(w, 102), nullptr, TRUE);
|
||||
return 0;
|
||||
@@ -741,16 +800,25 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (result->generation != v->generation || !IsWindowVisible(w))
|
||||
return 0;
|
||||
v->pending.erase(result->id);
|
||||
// At most ~3.3 MiB, irrespective of history length or source image size.
|
||||
if (v->thumbnails.size() >= 32)
|
||||
v->thumbnails.erase(v->thumbnails.begin());
|
||||
if (v->richTexts.size() >= 32)
|
||||
v->richTexts.erase(v->richTexts.begin());
|
||||
auto list=GetDlgItem(w,102);
|
||||
RECT client{}; GetClientRect(list,&client);
|
||||
std::set<std::wstring> visible;
|
||||
RECT changed{}; bool repaint=false;
|
||||
int top=(int)SendMessageW(list,LB_GETTOPINDEX,0,0);
|
||||
for(int i=std::max(0,top);i<(int)v->items.size();++i) {
|
||||
RECT row{}, intersection{};
|
||||
if(SendMessageW(list,LB_GETITEMRECT,i,(LPARAM)&row)==LB_ERR || row.top>=client.bottom) break;
|
||||
if(!IntersectRect(&intersection,&row,&client)) continue;
|
||||
visible.insert(v->items[i].id);
|
||||
if(v->items[i].id==result->id) { changed=intersection; repaint=true; }
|
||||
}
|
||||
if (!result->rtf.empty())
|
||||
v->richTexts[result->id] = std::move(result->rtf);
|
||||
else
|
||||
v->thumbnails[result->id] = std::move(result->pixels);
|
||||
InvalidateRect(GetDlgItem(w, 102), nullptr, FALSE);
|
||||
trimHistoryPreviews(v->thumbnails,visible);
|
||||
trimHistoryPreviews(v->richTexts,visible);
|
||||
if(repaint) InvalidateRect(list,&changed,FALSE);
|
||||
return 0;
|
||||
}
|
||||
case WM_MEASUREITEM:
|
||||
@@ -769,15 +837,29 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case WM_CONTEXTMENU:
|
||||
{
|
||||
auto list=GetDlgItem(w,102);
|
||||
if((HWND)wp!=list) break;
|
||||
POINT p{(short)LOWORD(lp),(short)HIWORD(lp)};
|
||||
if(lp!=-1) {
|
||||
POINT hit=p; ScreenToClient(list,&hit);
|
||||
auto item=SendMessageW(list,LB_ITEMFROMPOINT,0,MAKELPARAM(hit.x,hit.y));
|
||||
if(HIWORD(item)) return 0;
|
||||
SendMessageW(list,LB_SETCURSEL,LOWORD(item),0);
|
||||
updateHistoryActions(w,v);
|
||||
} else {
|
||||
RECT row{};
|
||||
auto index=SendMessageW(list,LB_GETCURSEL,0,0);
|
||||
if(index==LB_ERR) return 0;
|
||||
SendMessageW(list,LB_GETITEMRECT,index,(LPARAM)&row);
|
||||
p={row.left,row.top}; ClientToScreen(list,&p);
|
||||
}
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 110, L"Paste");
|
||||
AppendMenuW(menu, MF_STRING, 111, L"Paste plain text");
|
||||
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
|
||||
AppendMenuW(menu, MF_STRING, 113, L"Delete");
|
||||
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"Edit image in screenshot editor":L"Open full item");
|
||||
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
|
||||
POINT p;
|
||||
GetCursorPos(&p);
|
||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
||||
DestroyMenu(menu);
|
||||
if (cmd)
|
||||
@@ -1073,6 +1155,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
{
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||
560, 680);
|
||||
windowLayoutState(w)->autoOptions = true;
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
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);
|
||||
@@ -1113,6 +1196,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);
|
||||
button(w, L"OK", IDOK, 326, 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);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
@@ -1346,6 +1434,14 @@ struct SnippetView
|
||||
std::vector<Snippet> snippets;
|
||||
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)
|
||||
{
|
||||
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
|
||||
@@ -1353,6 +1449,7 @@ void fillSnippets(HWND w, SnippetView *v)
|
||||
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
|
||||
if (v->selection >= 0)
|
||||
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
}
|
||||
void collectSnippet(HWND w, SnippetView *v)
|
||||
{
|
||||
@@ -1389,11 +1486,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
value(w, 302, v->snippets[v->selection].trigger);
|
||||
value(w, 303, v->snippets[v->selection].text);
|
||||
}
|
||||
updateSnippetEditor(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 310)
|
||||
@@ -1402,8 +1495,6 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->snippets.push_back({L";new", L""});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 302, L";new");
|
||||
value(w, 303, L"");
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return 0;
|
||||
}
|
||||
@@ -1414,14 +1505,14 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 302, L"");
|
||||
value(w, 303, L"");
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (id == IDOK)
|
||||
@@ -1541,248 +1632,7 @@ void showHistory(App &app, bool visible)
|
||||
}
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
LogClear = 606;
|
||||
void fitLogDetails(HWND list)
|
||||
{
|
||||
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
|
||||
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
|
||||
RECT client{};
|
||||
GetClientRect(list, &client);
|
||||
int remaining = client.right;
|
||||
for (int column = 0; column < 3; ++column)
|
||||
remaining -= ListView_GetColumnWidth(list, column);
|
||||
int width = std::max(scale(list, 120), remaining);
|
||||
if (ListView_GetColumnWidth(list, 3) != width)
|
||||
ListView_SetColumnWidth(list, 3, width);
|
||||
RemovePropW(list, L"FittingLogDetails");
|
||||
}
|
||||
LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
auto result = DefSubclassProc(w, msg, wp, lp);
|
||||
if (msg == WM_SIZE)
|
||||
fitLogDetails(w);
|
||||
if (msg == WM_NOTIFY && lp)
|
||||
{
|
||||
auto *header = reinterpret_cast<NMHEADERW *>(lp);
|
||||
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
|
||||
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
|
||||
header->iItem >= 0 && header->iItem < 3)
|
||||
fitLogDetails(w);
|
||||
}
|
||||
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
|
||||
return result;
|
||||
}
|
||||
void copyLogRows(HWND w, App &app)
|
||||
{
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
std::wstring copied;
|
||||
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
||||
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
||||
{
|
||||
for (int column = 0; column < 4; ++column)
|
||||
{
|
||||
std::vector<wchar_t> text(32768);
|
||||
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
|
||||
if (column)
|
||||
copied += L'\t';
|
||||
copied += text.data();
|
||||
}
|
||||
copied += L"\r\n";
|
||||
}
|
||||
if (!copied.empty())
|
||||
{
|
||||
auto clip = textClip(copied);
|
||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||
app.ownSequence = writeClipboard(app.window, clip);
|
||||
}
|
||||
}
|
||||
void refreshLogView(HWND w, App &app)
|
||||
{
|
||||
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
|
||||
std::wstring contents;
|
||||
try
|
||||
{
|
||||
contents = readDiagnosticLog(previous);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
contents = L"Could not read the log: " + wide(e.what());
|
||||
}
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||
ListView_DeleteAllItems(list);
|
||||
std::wistringstream lines(contents);
|
||||
std::wstring line, timestamp;
|
||||
int row = 0;
|
||||
while (std::getline(lines, line))
|
||||
{
|
||||
if (!line.empty() && line.back() == L'\r')
|
||||
line.pop_back();
|
||||
if (line.empty())
|
||||
continue;
|
||||
auto firstSpace = line.find(L' ');
|
||||
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
|
||||
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
|
||||
{
|
||||
timestamp = line.substr(0, secondSpace);
|
||||
line.erase(0, secondSpace + 1);
|
||||
}
|
||||
auto id = line.find(L" id=");
|
||||
std::wstring attempt;
|
||||
if (id != line.npos)
|
||||
{
|
||||
auto end = line.find(L' ', id + 4);
|
||||
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
|
||||
}
|
||||
std::wstring event = line;
|
||||
if (event.starts_with(L" ageMs="))
|
||||
{
|
||||
auto end = event.find(L' ', 8);
|
||||
if (end != event.npos)
|
||||
event.erase(0, end + 1);
|
||||
}
|
||||
auto equals = event.find(L'=');
|
||||
if (equals != event.npos)
|
||||
{
|
||||
auto key = event.rfind(L' ', equals);
|
||||
if (key != event.npos)
|
||||
event.resize(key);
|
||||
}
|
||||
LVITEMW item{};
|
||||
item.mask = LVIF_TEXT;
|
||||
item.iItem = row;
|
||||
item.pszText = timestamp.data();
|
||||
ListView_InsertItem(list, &item);
|
||||
ListView_SetItemText(list, row, 1, attempt.data());
|
||||
ListView_SetItemText(list, row, 2, event.data());
|
||||
ListView_SetItemText(list, row, 3, line.data());
|
||||
++row;
|
||||
}
|
||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
if (row)
|
||||
ListView_EnsureVisible(list, row - 1, FALSE);
|
||||
fitLogDetails(list);
|
||||
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
||||
: L"Logging disabled. Showing previously saved entries.");
|
||||
}
|
||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (msg == WM_NCCREATE)
|
||||
{
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
||||
return TRUE;
|
||||
}
|
||||
if (!app)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
switch (msg)
|
||||
{
|
||||
case WM_COMMAND:
|
||||
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
||||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
||||
{
|
||||
refreshLogView(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try { copyLogRows(w, *app); }
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try
|
||||
{
|
||||
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
refreshLogView(w, *app);
|
||||
}
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
||||
break;
|
||||
[[fallthrough]];
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
case WM_NOTIFY:
|
||||
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
|
||||
{
|
||||
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
||||
if (GetKeyState(VK_CONTROL) & 0x8000)
|
||||
{
|
||||
if (key == 'A')
|
||||
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
||||
if (key == 'C')
|
||||
{
|
||||
try { copyLogRows(w, *app); }
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DPICHANGED:
|
||||
resizeForDpi(w, wp, lp);
|
||||
return 0;
|
||||
case WM_NCDESTROY:
|
||||
app->logWindow = nullptr;
|
||||
disposeFont(w);
|
||||
break;
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
}
|
||||
void showLogs(App &app)
|
||||
{
|
||||
if (app.logWindow)
|
||||
{
|
||||
showRequestedWindow(app.logWindow);
|
||||
activateWindow(app.logWindow);
|
||||
return;
|
||||
}
|
||||
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
|
||||
app.logWindow = w;
|
||||
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||
button(w, L"Refresh", LogRefresh, 330, 14, 110);
|
||||
button(w, L"Copy selected", LogCopy, 454, 14, 140);
|
||||
button(w, L"Clear log", LogClear, 608, 14, 110);
|
||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
|
||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&common);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
LogRows, 16, 82, 868, 454);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
||||
SetWindowSubclass(list, logListResizeProc, 3, 0);
|
||||
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
||||
const int widths[] = {155, 65, 195, 900};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
LVCOLUMNW column{};
|
||||
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
||||
column.pszText = const_cast<wchar_t *>(titles[i]);
|
||||
column.cx = scale(w, widths[i]);
|
||||
ListView_InsertColumn(list, i, &column);
|
||||
}
|
||||
auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.",
|
||||
0, 0, 16, 551, 720, 22);
|
||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->bottomLeft = hint;
|
||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||
arrangeWindow(w);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
#include "ui_log.inl"
|
||||
#else
|
||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||
#endif
|
||||
@@ -1871,8 +1721,9 @@ void showSnippets(App &app)
|
||||
edit(w, 302, 236, 44, 488);
|
||||
label(w, L"Replacement text", 236, 82, 480);
|
||||
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 Enter marker", 313, 436, 430, 190);
|
||||
button(w, L"Save", IDOK, 510, 478, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 624, 478, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
@@ -1902,11 +1753,17 @@ void showEntry(App &app, const std::wstring &id)
|
||||
try
|
||||
{
|
||||
auto clip = app.history->load(id);
|
||||
if(std::any_of(clip.formats.begin(),clip.formats.end(),[](const auto &format) {
|
||||
return format.id==CF_DIB || format.id==CF_DIBV5 || format.name==L"PNG";
|
||||
})) {
|
||||
launchScreenshotEditor(app,clip);
|
||||
return;
|
||||
}
|
||||
auto text = clip.text();
|
||||
if (text.empty())
|
||||
{
|
||||
MessageBoxW(app.historyWindow,
|
||||
L"This item contains an image or files. Use the preview or paste it into the "
|
||||
L"This item contains files. Paste it into the "
|
||||
L"destination application.",
|
||||
L"Clipboard item", MB_OK);
|
||||
return;
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
// Included inside namespace ks by ui.cpp; shares the native control/layout helpers.
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
LogClear = 606, LogProgram = 607, LogCommand = 608, LogResult = 609, LogProblems = 610,
|
||||
LogDetails = 611, LogReset = 612;
|
||||
struct LogViewState
|
||||
{
|
||||
std::vector<OperationLogRow> rows;
|
||||
std::vector<size_t> visible;
|
||||
int sortColumn = 0;
|
||||
bool descending = true, rendering = false;
|
||||
};
|
||||
LogViewState *logState(HWND w)
|
||||
{
|
||||
return reinterpret_cast<LogViewState *>(GetPropW(w, L"LogViewState"));
|
||||
}
|
||||
const std::wstring &logCell(const OperationLogRow &row, int column)
|
||||
{
|
||||
switch (column)
|
||||
{
|
||||
case 0:
|
||||
return row.time;
|
||||
case 1:
|
||||
return row.process;
|
||||
case 2:
|
||||
return row.command;
|
||||
case 3:
|
||||
return row.source;
|
||||
case 4:
|
||||
return row.target;
|
||||
default:
|
||||
return row.outcome;
|
||||
}
|
||||
}
|
||||
void showLogDetails(HWND w)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state || state->rendering)
|
||||
return;
|
||||
int selected = ListView_GetNextItem(GetDlgItem(w, LogRows), -1, LVNI_SELECTED);
|
||||
value(w, LogDetails,
|
||||
selected >= 0 && (size_t)selected < state->visible.size()
|
||||
? state->rows[state->visible[selected]].details
|
||||
: L"Select an operation to inspect its text and stages.");
|
||||
}
|
||||
void renderLogRows(HWND w, App &app)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state)
|
||||
return;
|
||||
state->rendering = true;
|
||||
state->visible.clear();
|
||||
auto matches = [&](int id, const std::wstring &field)
|
||||
{ return SendDlgItemMessageW(w, id, CB_GETCURSEL, 0, 0) <= 0 || value(w, id) == field; };
|
||||
for (size_t i = 0; i < state->rows.size(); ++i)
|
||||
{
|
||||
const auto &row = state->rows[i];
|
||||
if (matches(LogProgram, row.process) && matches(LogCommand, row.command) &&
|
||||
matches(LogResult, row.outcome) && (!checked(w, LogProblems) || row.problem))
|
||||
state->visible.push_back(i);
|
||||
}
|
||||
std::stable_sort(state->visible.begin(), state->visible.end(),
|
||||
[&](size_t a, size_t b)
|
||||
{
|
||||
const auto &left = logCell(state->rows[a], state->sortColumn);
|
||||
const auto &right = logCell(state->rows[b], state->sortColumn);
|
||||
if (left == right)
|
||||
return state->descending ? a > b : a < b;
|
||||
int comparison = CompareStringOrdinal(left.c_str(), -1, right.c_str(), -1, TRUE);
|
||||
return state->descending ? comparison == CSTR_GREATER_THAN
|
||||
: comparison == CSTR_LESS_THAN;
|
||||
});
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||
ListView_DeleteAllItems(list);
|
||||
for (size_t i = 0; i < state->visible.size(); ++i)
|
||||
{
|
||||
const auto &row = state->rows[state->visible[i]];
|
||||
LVITEMW item{};
|
||||
item.mask = LVIF_TEXT;
|
||||
item.iItem = (int)i;
|
||||
item.pszText = const_cast<wchar_t *>(row.time.c_str());
|
||||
ListView_InsertItem(list, &item);
|
||||
for (int column = 1; column < 6; ++column)
|
||||
ListView_SetItemText(list, (int)i, column, const_cast<wchar_t *>(logCell(row, column).c_str()));
|
||||
}
|
||||
HWND header = ListView_GetHeader(list);
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
HDITEMW item{};
|
||||
item.mask = HDI_FORMAT;
|
||||
Header_GetItem(header, i, &item);
|
||||
item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN);
|
||||
if (i == state->sortColumn)
|
||||
item.fmt |= state->descending ? HDF_SORTDOWN : HDF_SORTUP;
|
||||
Header_SetItem(header, i, &item);
|
||||
}
|
||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
state->rendering = false;
|
||||
value(w, LogStatus,
|
||||
std::to_wstring(state->visible.size()) + L" of " + std::to_wstring(state->rows.size()) +
|
||||
L" operations / events. " +
|
||||
(app.settings.writeLog ? L"Diagnostic logging enabled."
|
||||
: L"Logging disabled; saved entries only."));
|
||||
if (!state->visible.empty())
|
||||
ListView_SetItemState(list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||
showLogDetails(w);
|
||||
if (state->visible.empty())
|
||||
value(w, LogDetails,
|
||||
state->rows.empty()
|
||||
? L"No recorded operations. Use a conversion shortcut with logging enabled, then refresh."
|
||||
: L"No operations match these filters. Reset filters to show all entries.");
|
||||
}
|
||||
void fillLogFilter(HWND w, int id, const std::set<std::wstring> &choices)
|
||||
{
|
||||
auto previous = value(w, id);
|
||||
HWND combo = GetDlgItem(w, id);
|
||||
SendMessageW(combo, CB_RESETCONTENT, 0, 0);
|
||||
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"All");
|
||||
int selected = 0, index = 1;
|
||||
for (const auto &choice : choices)
|
||||
{
|
||||
if (choice.empty())
|
||||
continue;
|
||||
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)choice.c_str());
|
||||
if (choice == previous)
|
||||
selected = index;
|
||||
++index;
|
||||
}
|
||||
SendMessageW(combo, CB_SETCURSEL, selected, 0);
|
||||
}
|
||||
void refreshLogView(HWND w, App &app)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state)
|
||||
return;
|
||||
try
|
||||
{
|
||||
auto contents = readDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
state->rows = parseOperationLog(contents);
|
||||
if (contents.starts_with(L"No log yet.") || contents.starts_with(L"No previous log") ||
|
||||
contents == L"The log is empty.")
|
||||
state->rows.clear();
|
||||
std::set<std::wstring> programs, commands, results;
|
||||
for (const auto &row : state->rows)
|
||||
{
|
||||
programs.insert(row.process);
|
||||
commands.insert(row.command);
|
||||
results.insert(row.outcome);
|
||||
}
|
||||
fillLogFilter(w, LogProgram, programs);
|
||||
fillLogFilter(w, LogCommand, commands);
|
||||
fillLogFilter(w, LogResult, results);
|
||||
renderLogRows(w, app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
value(w, LogStatus, L"Could not read the log: " + wide(e.what()));
|
||||
}
|
||||
}
|
||||
void copyLogRows(HWND w, App &app)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
if (!state)
|
||||
return;
|
||||
std::wstring copied;
|
||||
HWND list = GetDlgItem(w, LogRows);
|
||||
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
||||
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
||||
{
|
||||
if ((size_t)row >= state->visible.size())
|
||||
continue;
|
||||
const auto &entry = state->rows[state->visible[row]];
|
||||
copied += entry.time + L"\t" + entry.process + L"\t" + entry.command + L"\t" + entry.source +
|
||||
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
|
||||
}
|
||||
if (!copied.empty())
|
||||
{
|
||||
auto clip = textClip(copied);
|
||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||
app.ownSequence = writeClipboard(app.window, clip);
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (msg == WM_NCCREATE)
|
||||
{
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
||||
SetPropW(w, L"LogViewState", new LogViewState);
|
||||
return TRUE;
|
||||
}
|
||||
if (!app)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
switch (msg)
|
||||
{
|
||||
case WM_COMMAND:
|
||||
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
||||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
||||
{
|
||||
refreshLogView(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if ((LOWORD(wp) >= LogProgram && LOWORD(wp) <= LogResult && HIWORD(wp) == CBN_SELCHANGE) ||
|
||||
(LOWORD(wp) == LogProblems && HIWORD(wp) == BN_CLICKED))
|
||||
{
|
||||
renderLogRows(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogReset && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
for (int id : {LogProgram, LogCommand, LogResult})
|
||||
SendDlgItemMessageW(w, id, CB_SETCURSEL, 0, 0);
|
||||
CheckDlgButton(w, LogProblems, BST_UNCHECKED);
|
||||
renderLogRows(w, *app);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try
|
||||
{
|
||||
copyLogRows(w, *app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
try
|
||||
{
|
||||
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
refreshLogView(w, *app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
||||
break;
|
||||
[[fallthrough]];
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
case WM_NOTIFY:
|
||||
if (((NMHDR *)lp)->idFrom != LogRows)
|
||||
break;
|
||||
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
|
||||
showLogDetails(w);
|
||||
if (((NMHDR *)lp)->code == LVN_COLUMNCLICK)
|
||||
{
|
||||
auto *state = logState(w);
|
||||
int column = ((NMLISTVIEW *)lp)->iSubItem;
|
||||
state->descending = state->sortColumn == column ? !state->descending : false;
|
||||
state->sortColumn = column;
|
||||
renderLogRows(w, *app);
|
||||
}
|
||||
if (((NMHDR *)lp)->code == LVN_KEYDOWN)
|
||||
{
|
||||
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
||||
if (GetKeyState(VK_CONTROL) & 0x8000)
|
||||
{
|
||||
if (key == 'A')
|
||||
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
||||
if (key == 'C')
|
||||
{
|
||||
try
|
||||
{
|
||||
copyLogRows(w, *app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
error(w, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DPICHANGED:
|
||||
resizeForDpi(w, wp, lp);
|
||||
return 0;
|
||||
case WM_NCDESTROY:
|
||||
delete logState(w);
|
||||
RemovePropW(w, L"LogViewState");
|
||||
app->logWindow = nullptr;
|
||||
disposeFont(w);
|
||||
break;
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
} // namespace
|
||||
void showLogs(App &app)
|
||||
{
|
||||
if (app.logWindow)
|
||||
{
|
||||
showRequestedWindow(app.logWindow);
|
||||
activateWindow(app.logWindow);
|
||||
return;
|
||||
}
|
||||
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher diagnostic operations", logProc, 1100, 760);
|
||||
app.logWindow = w;
|
||||
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 280, 140);
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||
button(w, L"Refresh", LogRefresh, 310, 14, 100);
|
||||
button(w, L"Copy selected", LogCopy, 424, 14, 140);
|
||||
button(w, L"Clear log", LogClear, 578, 14, 100);
|
||||
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
|
||||
label(w, L"Program", 16, 78, 170);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
|
||||
label(w, L"Command", 206, 78, 200);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
|
||||
label(w, L"Result", 426, 78, 300);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
|
||||
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
|
||||
button(w, L"Reset filters", LogReset, 938, 98, 146);
|
||||
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&common);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
LogRows, 16, 142, 1068, 260);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER |
|
||||
LVS_EX_INFOTIP);
|
||||
const wchar_t *titles[] = {L"Time", L"Program", L"Command",
|
||||
L"Source layout", L"Target layout", L"Result"};
|
||||
const int widths[] = {165, 145, 150, 130, 130, 320};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
LVCOLUMNW column{};
|
||||
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
||||
column.pszText = const_cast<wchar_t *>(titles[i]);
|
||||
column.cx = scale(w, widths[i]);
|
||||
ListView_InsertColumn(list, i, &column);
|
||||
}
|
||||
auto details = edit(w, LogDetails, 16, 416, 1068, 276,
|
||||
ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL | WS_BORDER);
|
||||
auto hint = control(
|
||||
w, L"STATIC",
|
||||
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
|
||||
16, 712, 860, 22);
|
||||
button(w, L"Close", IDCANCEL, 974, 708, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->details = details;
|
||||
layout->bottomLeft = hint;
|
||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||
arrangeWindow(w);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
namespace ks {
|
||||
inline constexpr UINT ScrollActivity=WM_APP+42;
|
||||
inline constexpr UINT_PTR ScrollFrameTimer=0x4b5301, ScrollIdleTimer=0x4b5302;
|
||||
struct WheelBatch {
|
||||
long long vertical{}, horizontal{};
|
||||
WORD keys{};
|
||||
LPARAM point{};
|
||||
bool frame{}, active{}, listBox{};
|
||||
int remainder{};
|
||||
};
|
||||
inline LRESULT CALLBACK wheelBatchProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
|
||||
{
|
||||
auto *batch=reinterpret_cast<WheelBatch *>(data);
|
||||
if(msg==WM_MOUSEWHEEL || msg==WM_MOUSEHWHEEL) {
|
||||
(msg==WM_MOUSEWHEEL?batch->vertical:batch->horizontal)+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||
batch->keys=GET_KEYSTATE_WPARAM(wp); batch->point=lp;
|
||||
if(!batch->active) {
|
||||
batch->active=true;
|
||||
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,TRUE);
|
||||
}
|
||||
// Do not restart the frame timer: an uninterrupted wheel stream must
|
||||
// keep moving, rather than wait for a debounce interval to expire.
|
||||
if(!batch->frame) batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||
SetTimer(w,ScrollIdleTimer,100,nullptr);
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_TIMER && wp==ScrollFrameTimer) {
|
||||
KillTimer(w,ScrollFrameTimer); batch->frame=false;
|
||||
if(batch->listBox && batch->vertical) {
|
||||
UINT lines=3; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||
if(lines==WHEEL_PAGESCROLL) {
|
||||
RECT client{}; GetClientRect(w,&client);
|
||||
auto height=SendMessageW(w,LB_GETITEMHEIGHT,0,0);
|
||||
lines=std::max<LRESULT>(1,client.bottom/std::max<LRESULT>(1,height));
|
||||
}
|
||||
auto delta=batch->vertical+batch->remainder;
|
||||
batch->remainder=(int)(delta%WHEEL_DELTA);
|
||||
auto top=SendMessageW(w,LB_GETTOPINDEX,0,0);
|
||||
auto count=SendMessageW(w,LB_GETCOUNT,0,0);
|
||||
auto next=std::clamp<long long>(top-(delta/WHEEL_DELTA)*lines,0,std::max<LRESULT>(0,count-1));
|
||||
if(next!=top) SendMessageW(w,LB_SETTOPINDEX,(WPARAM)next,0);
|
||||
batch->vertical=0;
|
||||
} else if(batch->vertical) {
|
||||
// Native edit/list-view/window handlers retain their own scrolling
|
||||
// semantics, including system settings and partial wheel deltas.
|
||||
auto delta=(short)std::clamp<long long>(batch->vertical,-32760,32760);
|
||||
batch->vertical-=delta;
|
||||
DefSubclassProc(w,WM_MOUSEWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||
}
|
||||
if(batch->horizontal) {
|
||||
auto delta=(short)std::clamp<long long>(batch->horizontal,-32760,32760);
|
||||
batch->horizontal-=delta;
|
||||
DefSubclassProc(w,WM_MOUSEHWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||
}
|
||||
if(batch->vertical || batch->horizontal)
|
||||
batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_TIMER && wp==ScrollIdleTimer) {
|
||||
if(batch->frame) return 0;
|
||||
KillTimer(w,ScrollIdleTimer); batch->active=false;
|
||||
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,FALSE);
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_NCDESTROY) {
|
||||
KillTimer(w,ScrollFrameTimer); KillTimer(w,ScrollIdleTimer);
|
||||
RemoveWindowSubclass(w,wheelBatchProc,id); delete batch;
|
||||
}
|
||||
return DefSubclassProc(w,msg,wp,lp);
|
||||
}
|
||||
inline void enableWheelBatching(HWND window,bool listBox=false)
|
||||
{
|
||||
if(!window) return;
|
||||
auto *batch=new WheelBatch; batch->listBox=listBox;
|
||||
if(!SetWindowSubclass(window,wheelBatchProc,ScrollFrameTimer,(DWORD_PTR)batch)) delete batch;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.12";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.22";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+25
-1
@@ -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", 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 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 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);
|
||||
@@ -38,7 +39,7 @@ $checks=0
|
||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||
function Window($class) {
|
||||
for($i=0;$i -lt 100;$i++) {
|
||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher log'}}
|
||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
|
||||
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||
if($window -ne [IntPtr]::Zero){return $window}
|
||||
Start-Sleep -Milliseconds 50
|
||||
@@ -127,6 +128,29 @@ try {
|
||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||
$config=Launch 'KSwitcher.Config.exe' '--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' ''
|
||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||
$settings=Window 'KSwitcher.Settings'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
#include <commctrl.h>
|
||||
@@ -598,6 +599,35 @@ int main(int argc, char **argv) {
|
||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
require(autoOptions!=nullptr,"auto-correction options open");
|
||||
ShowWindow(autoOptions,SW_HIDE);
|
||||
{
|
||||
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
|
||||
"auto-correction dialog supports resizing");
|
||||
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
|
||||
GetWindowRect(autoOptions,&original);
|
||||
// Use a client size above the scrolling baseline at every DPI.
|
||||
UINT dpi=GetDpiForWindow(autoOptions);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
|
||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
for(int i=0;i<3;++i) {
|
||||
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
|
||||
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
|
||||
"dictionary columns share extra width and receive extra height");
|
||||
}
|
||||
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
||||
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
|
||||
"auto-correction buttons follow bottom-right corner");
|
||||
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||
after[2].bottom<buttonAfter.top,
|
||||
"resized dictionary fields and footer do not overlap");
|
||||
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
|
||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
}
|
||||
for(int id:{411,412,413}) {
|
||||
RECT control{},client{};
|
||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||
@@ -699,16 +729,21 @@ int main(int argc, char **argv) {
|
||||
controlMoves=0;
|
||||
ValidateRect(settings,nullptr);
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
require(controlMoves==0,"wheel changes wait for the shared frame timer");
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||
controlMoves=0;
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
||||
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||
|
||||
+328
-9
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
@@ -21,6 +22,69 @@ ks::App *app{};
|
||||
HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
bool captured = false, failed = false;
|
||||
bool conversionOnly = false;
|
||||
int pasteBehavior = 0;
|
||||
int enterPresses = 0;
|
||||
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||
int droppedSelectionKeys = 0;
|
||||
bool temporaryClipboardExcluded = true;
|
||||
bool clipboardAccessTimedOut = false;
|
||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||||
(GetKeyState(VK_SHIFT) & 0x8000))
|
||||
{
|
||||
--droppedSelectionKeys;
|
||||
return 0;
|
||||
}
|
||||
// This bare EDIT control is our test fixture, not a full editor with
|
||||
// clipboard retries. Keep clipboard access on its owning UI thread while
|
||||
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
||||
// make the fixture silently discard the operation under test.
|
||||
struct ClipboardAccess
|
||||
{
|
||||
bool opened = false;
|
||||
~ClipboardAccess() { if (opened) CloseClipboard(); }
|
||||
} access;
|
||||
if (message == WM_CHAR && (wp == 3 || wp == 22))
|
||||
{
|
||||
auto deadline = GetTickCount64() + 1000;
|
||||
do
|
||||
{
|
||||
if (OpenClipboard(w)) { access.opened = true; break; }
|
||||
Sleep(10);
|
||||
} while (GetTickCount64() < deadline);
|
||||
if (!access.opened)
|
||||
{
|
||||
clipboardAccessTimedOut = true;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
|
||||
{
|
||||
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
|
||||
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
|
||||
if (message == WM_CHAR)
|
||||
{
|
||||
++copyRequests;
|
||||
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
|
||||
// Reproduce that outcome deterministically, without depending on
|
||||
// the timing of the Windows clipboard-history service.
|
||||
if (copyFailuresRemaining > 0)
|
||||
{
|
||||
--copyFailuresRemaining;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message == WM_PASTE && pasteBehavior)
|
||||
{
|
||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||||
return DefSubclassProc(w, message, wp, lp);
|
||||
}
|
||||
int stage = 0, ru = -1, en = -1;
|
||||
size_t punctuationCase = 0;
|
||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||
@@ -37,13 +101,15 @@ std::string diagnosticLog()
|
||||
size_t diagnosticStart{};
|
||||
void require(bool ok, const char *name)
|
||||
{
|
||||
if (!ok)
|
||||
if (!ok || clipboardAccessTimedOut)
|
||||
{
|
||||
wchar_t observed[2048]{};
|
||||
GetWindowTextW(field, observed, 2048);
|
||||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
||||
if (clipboardAccessTimedOut)
|
||||
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
|
||||
throw std::runtime_error(name);
|
||||
}
|
||||
std::cout << "PASS " << name << '\n';
|
||||
@@ -95,6 +161,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
if (msg == WM_TIMER)
|
||||
{
|
||||
// Input tests must not start a network update check. Its close veto can
|
||||
// otherwise leave a completed test running after the editor is gone.
|
||||
if (app->window) KillTimer(app->window, 5);
|
||||
try
|
||||
{
|
||||
if (GetTickCount64() > deadline)
|
||||
@@ -143,10 +212,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 3:
|
||||
require(text() == L"Hello World", "case conversion");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Case);
|
||||
break;
|
||||
case 4:
|
||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||||
prepare(L"OLDghbdtn", false);
|
||||
record(L"ghbdtn");
|
||||
app->transform(ks::Direct + ru);
|
||||
@@ -172,12 +243,20 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
"busy conversion attempt is logged");
|
||||
require(diagnostics.find("repeat=3") != std::string::npos,
|
||||
"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");
|
||||
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
|
||||
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
|
||||
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
|
||||
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
|
||||
"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"}};
|
||||
prepare(L"prefix ;addr", false);
|
||||
record(L";addr");
|
||||
@@ -235,6 +314,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
||||
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
prepare(L"ghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
@@ -246,6 +326,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
||||
nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
prepare(L"(ljvf ", false);
|
||||
record(L"(ljvf ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
@@ -281,7 +362,170 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 17:
|
||||
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);
|
||||
prepare(L"PuntoSwitcher ", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 44:
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 45:
|
||||
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||
droppedSelectionKeys = 5;
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 46:
|
||||
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"suffix recovery restores original clipboard");
|
||||
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||
record(L"snipping ");
|
||||
droppedSelectionKeys = 4;
|
||||
app->transform(ks::Direct + en);
|
||||
break;
|
||||
case 47:
|
||||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||
finish();
|
||||
break;
|
||||
case 30:
|
||||
case 32:
|
||||
case 34:
|
||||
case 37:
|
||||
// Earlier password/word tests replace the original multiline
|
||||
// editor with a single-line EDIT. Restore the snippet fixture:
|
||||
// a single-line control cannot insert the tested Enter action.
|
||||
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
|
||||
{
|
||||
DestroyWindow(field);
|
||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
|
||||
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
}
|
||||
if (!captured)
|
||||
{
|
||||
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->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||
app->settings.switchLayout = false;
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
app->autoPredictor = ks::AutoCorrector{};
|
||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||||
enterPresses = 0;
|
||||
copyRequests = 0;
|
||||
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
|
||||
temporaryClipboardExcluded = true;
|
||||
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(copyRequests == 2 && copyFailuresRemaining == 0,
|
||||
"snippet retries a silently dropped copy without duplicating input");
|
||||
require(temporaryClipboardExcluded,
|
||||
"temporary snippet clipboard contents are excluded from Windows history");
|
||||
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||||
require(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");
|
||||
stage = 37;
|
||||
break;
|
||||
case 38:
|
||||
require(copyRequests == 3, "copy retries are bounded to three attempts");
|
||||
require(text() == L"prefix ;r x" && enterPresses == 0,
|
||||
"exhausted copy retries preserve trigger and following input without Enter");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"exhausted copy retries restore the clipboard");
|
||||
copyFailuresRemaining = 0;
|
||||
finish();
|
||||
break;
|
||||
case 20:
|
||||
if (!captured)
|
||||
{
|
||||
@@ -334,8 +578,26 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
stage = 21;
|
||||
break;
|
||||
case 21:
|
||||
{
|
||||
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
||||
finish();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -358,6 +620,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
} // namespace
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
auto root = std::filesystem::temp_directory_path() /
|
||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||
@@ -373,9 +636,22 @@ int main(int argc, char **argv)
|
||||
ks::showLogs(instance);
|
||||
HWND viewer = instance.logWindow;
|
||||
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,
|
||||
"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");
|
||||
SetFocus(rows);
|
||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
||||
@@ -385,10 +661,11 @@ int main(int argc, char **argv)
|
||||
ks::showLogs(instance);
|
||||
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
||||
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);
|
||||
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")
|
||||
{
|
||||
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
||||
@@ -413,7 +690,46 @@ int main(int argc, char **argv)
|
||||
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
||||
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
||||
"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);
|
||||
instance.window = viewer;
|
||||
try
|
||||
@@ -459,8 +775,11 @@ int main(int argc, char **argv)
|
||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||
deadline = GetTickCount64() + 30000;
|
||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||
stage = 20;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||
stage = 30;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
app->execute();
|
||||
}
|
||||
|
||||
+100
-1
@@ -24,6 +24,105 @@ int main()
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||
"new screenshot editor starts with the pencil tool");
|
||||
{
|
||||
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||
auto mouse=[&](UINT message,POINT p,bool shift=false) {
|
||||
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
||||
};
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||
mouse(WM_MOUSEMOVE,{100,90},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
|
||||
"50 pixel deviation keeps the current axis");
|
||||
mouse(WM_MOUSEMOVE,{100,91},true);
|
||||
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
|
||||
"51 pixel deviation turns vertically and preserves the horizontal segment");
|
||||
mouse(WM_MOUSEMOVE,{70,140});
|
||||
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
|
||||
"releasing Shift resumes freehand in the same stroke");
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||
mouse(WM_MOUSEMOVE,{140,150},true);
|
||||
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
|
||||
"pressing Shift mid-stroke anchors at the last drawn point");
|
||||
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
|
||||
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||
mouse(WM_MOUSEMOVE,{150,200},true);
|
||||
mouse(WM_LBUTTONUP,{150,210},true);
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
|
||||
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
|
||||
SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
|
||||
c.marks.clear(); c.selected=-1;
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||
mouse(WM_MOUSEMOVE,{160+sx,120},true);
|
||||
for(int step=2;step<=90;++step)
|
||||
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
|
||||
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
|
||||
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
|
||||
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
|
||||
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
|
||||
for(int step=1;step<=90;++step)
|
||||
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
|
||||
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
|
||||
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
|
||||
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
|
||||
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
|
||||
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
|
||||
"Shift snaps to exact 45 degree diagonals in all four directions");
|
||||
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||
mouse(WM_MOUSEMOVE,{100,40},true);
|
||||
mouse(WM_MOUSEMOVE,{160,100},true);
|
||||
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
|
||||
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
|
||||
"large deviation turns a horizontal segment diagonally without losing its corner");
|
||||
mouse(WM_MOUSEMOVE,{200,100},true);
|
||||
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
|
||||
"small perpendicular deviation keeps the diagonal axis");
|
||||
mouse(WM_MOUSEMOVE,{260,120},true);
|
||||
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
|
||||
"diagonal deviation beyond 50 pixels turns back to horizontal");
|
||||
mouse(WM_LBUTTONUP,{260,120},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||
POINT start{sx>0?310L:9L,120};
|
||||
mouse(WM_LBUTTONDOWN,start,true);
|
||||
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
|
||||
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
|
||||
auto end=c.stroke.to;
|
||||
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
|
||||
std::abs(end.x-start.x)==std::abs(end.y-start.y),
|
||||
"diagonal stays at 45 degrees and inside image boundaries");
|
||||
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||
mouse(WM_LBUTTONDOWN,{40,40});
|
||||
mouse(WM_MOUSEMOVE,{100,70});
|
||||
mouse(WM_LBUTTONUP,{120,90});
|
||||
SetKeyboardState(savedKeys);
|
||||
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
|
||||
c.marks[0].to.x==120 && c.marks[0].to.y==90,
|
||||
"Ctrl keeps its existing drawing behavior without constraining the pencil");
|
||||
c.marks.clear(); c.selected=-1;
|
||||
}
|
||||
for(int size:{32,48,64,96}) {
|
||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||
ICONINFO info{};
|
||||
@@ -76,7 +175,7 @@ int main()
|
||||
}
|
||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
|
||||
require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
|
||||
RECT client{}; GetClientRect(window,&client);
|
||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||
ks::updateStatus(c);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "ui_scroll.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
int forwarded{};
|
||||
long long totalDelta{};
|
||||
LRESULT CALLBACK observe(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(msg==WM_MOUSEWHEEL) { ++forwarded; totalDelta+=GET_WHEEL_DELTA_WPARAM(wp); }
|
||||
return DefSubclassProc(w,msg,wp,lp);
|
||||
}
|
||||
void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(message); }
|
||||
void wheel(HWND w,int delta) { SendMessageW(w,WM_MOUSEWHEEL,MAKEWPARAM(0,(WORD)(short)delta),0); }
|
||||
void flush(HWND w) { SendMessageW(w,WM_TIMER,ks::ScrollFrameTimer,0); }
|
||||
}
|
||||
int main()
|
||||
{
|
||||
INITCOMMONCONTROLSEX init{sizeof(init),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&init);
|
||||
auto instance=GetModuleHandleW(nullptr);
|
||||
auto parent=CreateWindowW(L"STATIC",L"Scroll regression",WS_OVERLAPPEDWINDOW,0,0,500,400,nullptr,nullptr,instance,nullptr);
|
||||
try {
|
||||
auto list=CreateWindowW(L"LISTBOX",L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||
for(int i=0;i<2000;++i) SendMessageW(list,LB_ADDSTRING,0,(LPARAM)L"row");
|
||||
SendMessageW(list,LB_SETCURSEL,5,0);
|
||||
SendMessageW(list,LB_SETTOPINDEX,0,0);
|
||||
ks::enableWheelBatching(list,true);
|
||||
for(int i=0;i<1000;++i) wheel(list,-120);
|
||||
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"burst is deferred to one frame");
|
||||
flush(list);
|
||||
UINT lines{}; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"burst moves list");
|
||||
require(SendMessageW(list,LB_GETCURSEL,0,0)==5,"scroll does not change selection");
|
||||
for(int i=0;i<1000;++i) wheel(list,120);
|
||||
flush(list);
|
||||
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"reverse burst clamps at top");
|
||||
wheel(list,-60); flush(list);
|
||||
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"partial wheel step is retained");
|
||||
wheel(list,-60); flush(list);
|
||||
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"partial steps combine across frames");
|
||||
DestroyWindow(list);
|
||||
for(auto cls:{L"EDIT",WC_LISTVIEWW,L"COMBOBOX",L"STATIC"}) {
|
||||
auto w=CreateWindowW(cls,L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||
require(w!=nullptr,"native scroll control created");
|
||||
SetWindowSubclass(w,observe,1,0); ks::enableWheelBatching(w);
|
||||
forwarded=0; totalDelta=0;
|
||||
for(int i=0;i<100;++i) wheel(w,-120);
|
||||
require(forwarded==0,"native wheel burst is batched");
|
||||
flush(w);
|
||||
require(forwarded==1 && totalDelta==-12000,"one native dispatch preserves burst distance");
|
||||
wheel(w,60); flush(w);
|
||||
require(forwarded==2 && totalDelta==-11940,"native partial steps are preserved");
|
||||
forwarded=0; totalDelta=0;
|
||||
for(int i=0;i<400;++i) wheel(w,-120);
|
||||
flush(w); flush(w);
|
||||
require(forwarded==2 && totalDelta==-48000,"large native burst is not truncated to 16 bits");
|
||||
SendMessageW(w,WM_TIMER,ks::ScrollIdleTimer,0);
|
||||
DestroyWindow(w);
|
||||
}
|
||||
DestroyWindow(parent);
|
||||
std::cout<<"Passed shared wheel batching regressions (list, edit, table, combo, window)\n";
|
||||
return 0;
|
||||
} catch(const std::exception &e) {
|
||||
DestroyWindow(parent); std::cerr<<e.what()<<'\n'; return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "autocorrect.h"
|
||||
#include "history_preview.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "storage.h"
|
||||
#include "diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
@@ -21,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
|
||||
bool thrown = false;
|
||||
try
|
||||
{
|
||||
{
|
||||
std::map<std::wstring,int> previews;
|
||||
std::set<std::wstring> visible;
|
||||
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
||||
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
||||
ks::trimHistoryPreviews(previews,visible);
|
||||
require(previews.size()==32,"preview cache stays bounded");
|
||||
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
||||
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
||||
ks::trimHistoryPreviews(previews,visible);
|
||||
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
||||
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
||||
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
||||
}
|
||||
f();
|
||||
}
|
||||
catch (...)
|
||||
@@ -44,6 +60,34 @@ int main(int argc, char **argv)
|
||||
}
|
||||
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",
|
||||
"diagnostic word preserved");
|
||||
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
||||
@@ -148,6 +192,26 @@ int main(int argc, char **argv)
|
||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||
ks::Run suffixRun;
|
||||
suffixRun.keys = L"snipping ";
|
||||
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
|
||||
"recent Cyrillic suffix matches physical keys across layouts");
|
||||
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
|
||||
"recent English suffix matches physical keys");
|
||||
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
|
||||
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
|
||||
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
|
||||
"suffix recovery rejects unrelated, empty and complete ranges");
|
||||
suffixRun.overflow = true;
|
||||
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
|
||||
"suffix recovery rejects overflowed tracking");
|
||||
suffixRun.overflow = false;
|
||||
suffixRun.keys = L"LinkedIn ";
|
||||
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
|
||||
"ChatGPT partial CamelCase selection matches tracked physical suffix");
|
||||
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
|
||||
suffixRun.match(L"лувШт ", layouts, en) < 0,
|
||||
"CamelCase conversion still requires the complete tracked text after recovery");
|
||||
ks::AutoCorrector corrector;
|
||||
ks::Settings autoSettings;
|
||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||
@@ -276,6 +340,15 @@ int main(int argc, char **argv)
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"never-correct list takes precedence over personal word");
|
||||
options.autoNever.clear();
|
||||
options.autoWords[L"uk"].push_back(L"робить");
|
||||
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
||||
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
||||
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
||||
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
||||
"punctuation-key correction still rejects digits");
|
||||
options.autoMinLength = 1;
|
||||
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
||||
"known English ye remains protected at minimum length one");
|
||||
options.cycle = {L"en", L"ru"};
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"personal dictionary respects disabled target layouts");
|
||||
@@ -284,6 +357,22 @@ int main(int argc, char **argv)
|
||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
|
||||
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
|
||||
"cycle skips unchanged Russian to Ukrainian and wraps to English");
|
||||
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
|
||||
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
|
||||
"cycle skips unchanged Ukrainian to Russian");
|
||||
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
|
||||
"cycle keeps the next language when text changes");
|
||||
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
|
||||
"cycle preserves configured order for changed text");
|
||||
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
|
||||
"cycle does not use disabled English when all candidates are unchanged");
|
||||
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
|
||||
"unchanged digits finish after a bounded cycle");
|
||||
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
|
||||
"source outside cycle still skips unchanged candidates");
|
||||
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
||||
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
||||
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
||||
@@ -292,6 +381,12 @@ int main(int argc, char **argv)
|
||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||
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"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::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||
|
||||
@@ -10,6 +10,12 @@ try {
|
||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||
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 ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||
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 ((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}
|
||||
$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'
|
||||
$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'
|
||||
|
||||
Reference in New Issue
Block a user