Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94cab4c112 | |||
| d13e9849a7 | |||
| 004fee4976 | |||
| b08c5ff8ff | |||
| b6e7bda220 | |||
| 89e506faae | |||
| c78b51d341 | |||
| 0184b837f4 |
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.29 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.36 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
@@ -65,6 +65,15 @@ else()
|
|||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_executable(KSwitcherDictionaryTests tests/dictionary.cpp)
|
||||||
|
target_link_libraries(KSwitcherDictionaryTests PRIVATE kscore psapi)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherDictionaryTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherDictionaryTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME dictionary-regressions COMMAND KSwitcherDictionaryTests)
|
||||||
|
set_tests_properties(dictionary-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
|||||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||||
Double-click the tray icon to open Settings, the bold default menu item.
|
Double-click the tray icon to open Settings, the bold default menu item.
|
||||||
|
Closing Settings with unsaved changes (including accepted auto-correction or OCR options)
|
||||||
|
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
|
||||||
|
If saving fails, the window stays open with your edits intact.
|
||||||
Menus support access keys: press the underlined letter while the menu is open (for example,
|
Menus support access keys: press the underlined letter while the menu is open (for example,
|
||||||
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
||||||
to activate a button, toggle a checkbox or focus a labeled field. Press Alt to reveal
|
to activate a button, toggle a checkbox or focus a labeled field. Dialog underline visibility
|
||||||
the underlines when Windows hides them. Shortcut-capture fields still record Alt combinations;
|
still follows Windows preferences; press Alt in a dialog to reveal them.
|
||||||
|
From v0.6.33, tray and history menus show access letters in the right-hand column.
|
||||||
|
Press the menu letter without Alt: Alt dismisses an open menu. Shortcut-capture fields still record Alt combinations;
|
||||||
press Tab to leave a shortcut field before using form access keys.
|
press Tab to leave a shortcut field before using form access keys.
|
||||||
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||||
@@ -170,7 +175,8 @@ for shapes.
|
|||||||
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
||||||
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
||||||
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
||||||
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
to pan an enlarged image. Panning is limited to axes where the image exceeds the viewport;
|
||||||
|
an image that fits stays centered. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
||||||
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
||||||
The wheel without Ctrl still adjusts the object under the cursor.
|
The wheel without Ctrl still adjusts the object under the cursor.
|
||||||
|
|
||||||
@@ -186,6 +192,9 @@ Every shape has a round rotation handle above its outline. Drag it to rotate aro
|
|||||||
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
||||||
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
||||||
undo and redo it. The square handle still controls outline thickness.
|
undo and redo it. The square handle still controls outline thickness.
|
||||||
|
From v0.6.34, endpoint, shape-corner and number-radius handles show the four-direction move cursor.
|
||||||
|
Thickness handles retain directional resize cursors; rotation retains the hand cursor. Dragging,
|
||||||
|
snapping and shape constraints are unchanged.
|
||||||
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
||||||
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||||
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||||
@@ -271,7 +280,11 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
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
|
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||||
history and cloud synchronization. Paste is not blindly retried.
|
history and cloud synchronization. Paste is not blindly retried: with manual paste readback
|
||||||
|
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
|
||||||
|
original and fresh native queries confirm that exactly the original range is still selected.
|
||||||
|
Other controls and unknown/changed selections are not retried. A verified failed paste clears
|
||||||
|
repeat-conversion state and does not switch the Windows layout.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||||
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||||
@@ -614,7 +627,12 @@ support automatic correction. Dictionary recognition is heuristic, not an unders
|
|||||||
subtitle vocabulary is incomplete, and uncommon names or jargon can need personal exceptions.
|
subtitle vocabulary is incomplete, and uncommon names or jargon can need personal exceptions.
|
||||||
|
|
||||||
No network service or language model is used. The three dictionary files occupy about 2.6 MB on disk;
|
No network service or language model is used. The three dictionary files occupy about 2.6 MB on disk;
|
||||||
the English/Ukrainian lookup data uses less than 3 MiB of RAM and is loaded on first automatic check.
|
the bundled EN/UK/RU lookup payload uses about 0.90 MiB of RAM. When automatic correction is enabled,
|
||||||
|
selected dictionaries are loaded before typing and shared by the input predictor and worker.
|
||||||
|
Words are stored in compact indexed blocks; all words and frequency ranks are retained, and editable
|
||||||
|
UTF-8 dictionary files keep the same format. Custom dictionaries can use more memory.
|
||||||
|
`build.ps1 -Test` checks every bundled word/rank and measures lookup speed; CMake exposes the same
|
||||||
|
check as `dictionary-regressions`. The payload figure excludes the rest of the process and allocator overhead.
|
||||||
Keep `dictionaries/` beside the executable when copying the app. These word lists derive from
|
Keep `dictionaries/` beside the executable when copying the app. These word lists derive from
|
||||||
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) and are separately licensed under
|
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) and are separately licensed under
|
||||||
CC BY-SA 4.0; attribution, pinned source revision and license are in `dictionaries/`.
|
CC BY-SA 4.0; attribution, pinned source revision and license are in `dictionaries/`.
|
||||||
@@ -685,3 +703,11 @@ Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
|||||||
legacy version tag: clients that have not installed the bridge would select it.
|
legacy version tag: clients that have not installed the bridge would select it.
|
||||||
Keep the compatibility bridge in the first 100 releases returned by Gitea while
|
Keep the compatibility bridge in the first 100 releases returned by Gitea while
|
||||||
supporting old clients; their immutable update code only reads that page.
|
supporting old clients; their immutable update code only reads that page.
|
||||||
|
|
||||||
|
### Editors that omit copied trailing spaces
|
||||||
|
|
||||||
|
From v0.6.31, KSwitcher recovers trailing ordinary spaces omitted by Ctrl+C only when Windows accessibility proves the complete selection. The same focused control and range are rechecked before paste, and the result is read back even without Write log. Recent-text conversion, automatic correction, repeat conversion and Undo retain exact verification. Missing letters, tabs, line breaks and unavailable or ambiguous selections are not reconstructed. No setting or shortcut has changed.
|
||||||
|
|
||||||
|
### Notification registration and Viber — v0.6.36
|
||||||
|
|
||||||
|
Notification registration runs in KSwitcher.Screenshot.exe; keep all three executables together. Startup registration is asynchronous. Viber pastes allow 75 ms for clipboard notifications before Ctrl+V, retaining focus, ownership and selection checks. This mitigates an intermittent clipboard timing issue; it does not guarantee all Viber failures are resolved. UI Automation is unchanged.
|
||||||
|
|||||||
Binary file not shown.
+10
-17
@@ -1,29 +1,22 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.29 — 2026-11-02
|
## v0.6.36 — 2026-11-04
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item.
|
- Notification registration now runs in the existing KSwitcher.Screenshot.exe helper. Startup launches it asynchronously and checks its exit status without blocking keyboard work; update notification registration waits in its background worker. Keep all three executables together. UI Automation and notification activation routes remain unchanged.
|
||||||
- Clearing requires confirmation, with **No** selected by default. It removes all saved entries, including pinned entries and entries hidden by the current search. Cancelling leaves history unchanged.
|
- Viber paste now allows 75 ms for clipboard notifications to settle before sending Ctrl+V, then rechecks input, focus and clipboard ownership. This mitigates a suspected clipboard timing race: an intermittent corrupted Cyrillic paste was reproduced on the prior build. It is not a guarantee that every Viber timing issue is resolved; no blind paste retries or relaxed selection checks were added.
|
||||||
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action.
|
- Added regression coverage for an explicitly selected middle range followed by a repeated shortcut without selection, preserving surrounding text, trailing spaces and the original clipboard. Updated the integration fixture to initialize shared predictor dictionaries.
|
||||||
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management.
|
- Added reproducible standalone COM, UI Automation and Shell memory measurement tools. No shortcuts, settings or defaults changed.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
The final build passed isolated checks for the new button, cancellation, confirmed deletion of pinned/search-hidden entries and persistence after reloading the history store. 48 updater regression checks passed; all executable versions and release-manifest hashes were verified. The full desktop-interaction suite was not rerun for this focused patch.
|
Full staged build and tests cover dictionary equivalence for all 140,939 words, 105 sync checks, wheel batching, 304 native checks, 559 input checks, screenshot UI/PNG/registration, 48 updater checks and updater progress UI. Conversion integration passed 43 assertions. Eight actual Viber conversions passed on the development build, including two complete long-text layout cycles and selected/repeated middle-word conversion; test messages were not sent.
|
||||||
|
|
||||||
## Known limitations
|
A controlled fresh-launch before/after comparison (five private-working-set samples each, same installation and settings) measured 4.69 → 4.18 MiB, about 0.51 MiB saved. This is one local comparison, not a fixed memory limit or a long-term idle guarantee. The prior compact dictionaries and cache/idle memory reductions remain included.
|
||||||
Existing asynchronous-editor selection/clipboard limitations remain; this release does not fix the reported ChatGPT selection mismatch. Dictionary typo correction and OpenBao synchronization remain experimental as documented in v0.6.28.
|
|
||||||
|
|
||||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
## Upgrade
|
||||||
|
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
For manual installation, exit KSwitcher, close screenshot editors 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 includes all three executables, a release manifest, offline dictionaries, documentation and license notices. No personal settings, passwords, server address or clipboard history are included.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together. The package contains no personal settings, credentials or clipboard history.
|
||||||
|
|
||||||
Build and package:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build.ps1 -Stage -Test
|
|
||||||
.\package.ps1 -PublishRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
@@ -1 +1 @@
|
|||||||
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 KSwitcher-win-x64.zip
|
794d74113bddf13ffee7317cadee9c472bca3b84b3361796db58ff8023f66571 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
|
||||||
|
& .\build\KSwitcherDictionaryTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
|
||||||
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||||
& .\build\KSwitcherSyncTests.exe
|
& .\build\KSwitcherSyncTests.exe
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,29,0
|
FILEVERSION 0,6,36,0
|
||||||
PRODUCTVERSION 0,6,29,0
|
PRODUCTVERSION 0,6,36,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.29"
|
VALUE "FileVersion", "0.6.36"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+144
-8
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include <utility>
|
||||||
#include "../vendor/json.hpp"
|
#include "../vendor/json.hpp"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
@@ -76,6 +77,8 @@ class Transaction
|
|||||||
mutable bool invalidLogged{};
|
mutable bool invalidLogged{};
|
||||||
unsigned copyNumber{};
|
unsigned copyNumber{};
|
||||||
std::wstring adopted;
|
std::wstring adopted;
|
||||||
|
std::unique_ptr<PasteProbe> selectionProof;
|
||||||
|
std::wstring provenSelection;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v)
|
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v)
|
||||||
@@ -250,8 +253,27 @@ class Transaction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selectionProof.reset();
|
||||||
|
provenSelection.clear();
|
||||||
|
// Viber and some other editors omit trailing ASCII spaces from Ctrl+C.
|
||||||
|
// Recover only from a real selection, never from the recorded keys alone.
|
||||||
|
if (valid() && !text.empty() && text.size() < 8192 && text.back() != L' ')
|
||||||
|
{
|
||||||
|
auto proof = std::make_unique<PasteProbe>(true, target);
|
||||||
|
auto selected = proof->read(1, false);
|
||||||
|
if (selected && selected->size() > text.size() && selected->starts_with(text) &&
|
||||||
|
selected->find_first_not_of(L' ', text.size()) == std::wstring::npos && valid())
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Copy: trailing spaces verified via accessibility copied=" +
|
||||||
|
diagnosticText(text) + L" selected=" + diagnosticText(*selected));
|
||||||
|
text = *selected;
|
||||||
|
provenSelection = text;
|
||||||
|
selectionProof = std::move(proof);
|
||||||
|
}
|
||||||
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
bool recoveredSelection() const { return selectionProof != nullptr; }
|
||||||
bool select(size_t n)
|
bool select(size_t n)
|
||||||
{
|
{
|
||||||
return selectPrevious(n, [&] { return valid(); });
|
return selectPrevious(n, [&] { return valid(); });
|
||||||
@@ -324,9 +346,26 @@ class Transaction
|
|||||||
}
|
}
|
||||||
bool paste(const Clip &clip)
|
bool paste(const Clip &clip)
|
||||||
{
|
{
|
||||||
|
if (selectionProof)
|
||||||
|
{
|
||||||
|
auto selected = selectionProof->read(1, false);
|
||||||
|
if (!selected || *selected != provenSelection || !valid())
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Paste cancelled: recovered selection changed or unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!valid())
|
if (!valid())
|
||||||
return false;
|
return false;
|
||||||
write(clip);
|
write(clip);
|
||||||
|
// Viber's Qt editor observes clipboard changes asynchronously. Give it
|
||||||
|
// a chance to discard the data object from Ctrl+C before requesting paste.
|
||||||
|
// Recheck ownership and input below; never retry a possibly applied paste.
|
||||||
|
if (lower(processName(target)) == L"viber")
|
||||||
|
{
|
||||||
|
Sleep(75);
|
||||||
|
diagnosticEvent(L"Paste: Viber clipboard notification settling interval completed");
|
||||||
|
}
|
||||||
for (int i = 0; i < 2; ++i)
|
for (int i = 0; i < 2; ++i)
|
||||||
{
|
{
|
||||||
check();
|
check();
|
||||||
@@ -338,6 +377,32 @@ class Transaction
|
|||||||
Sleep(300);
|
Sleep(300);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
bool nativeSelectionIs(const std::wstring &original) const
|
||||||
|
{
|
||||||
|
if (!valid()) return false;
|
||||||
|
GUITHREADINFO gui{sizeof(gui)};
|
||||||
|
if (!GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui) || !gui.hwndFocus) return false;
|
||||||
|
wchar_t cls[32]{};
|
||||||
|
GetClassNameW(gui.hwndFocus, cls, 32);
|
||||||
|
if (_wcsicmp(cls, L"Edit") != 0 ||
|
||||||
|
(GetWindowLongPtrW(gui.hwndFocus, GWL_STYLE) & (ES_PASSWORD | ES_READONLY))) return false;
|
||||||
|
auto query = [&](UINT message, WPARAM wp, LPARAM lp, DWORD_PTR &result)
|
||||||
|
{
|
||||||
|
return SendMessageTimeoutW(gui.hwndFocus, message, wp, lp,
|
||||||
|
SMTO_ABORTIFHUNG | SMTO_BLOCK, 100, &result) != 0;
|
||||||
|
};
|
||||||
|
DWORD_PTR range{}, length{}, copied{};
|
||||||
|
if (!query(EM_GETSEL, 0, 0, range) || (DWORD)range == 0xffffffff ||
|
||||||
|
size_t(HIWORD(range) - LOWORD(range)) != original.size() ||
|
||||||
|
!query(WM_GETTEXTLENGTH, 0, 0, length) || length > 65534 || HIWORD(range) > length) return false;
|
||||||
|
std::wstring content(length + 1, L'\0');
|
||||||
|
if (!query(WM_GETTEXT, content.size(), (LPARAM)content.data(), copied) || copied != length) return false;
|
||||||
|
DWORD_PTR after{};
|
||||||
|
GUITHREADINFO focused{sizeof(focused)};
|
||||||
|
return query(EM_GETSEL, 0, 0, after) && after == range && valid() &&
|
||||||
|
GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &focused) && focused.hwndFocus == gui.hwndFocus &&
|
||||||
|
content.substr(LOWORD(range), original.size()) == original;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||||
@@ -376,6 +441,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
if (notificationRegistration) CloseHandle(notificationRegistration);
|
||||||
if (syncWorker.joinable()) syncWorker.join();
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
if (updateWorker.joinable())
|
if (updateWorker.joinable())
|
||||||
updateWorker.join();
|
updateWorker.join();
|
||||||
@@ -586,7 +652,7 @@ void App::applySettings(const Settings &requested)
|
|||||||
++inputVersion;
|
++inputVersion;
|
||||||
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
|
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
|
||||||
}
|
}
|
||||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
// Release the worker's old snapshot; the next check shares the freshly loaded predictor.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
scheduleHistoryOcr(s);
|
scheduleHistoryOcr(s);
|
||||||
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
||||||
@@ -647,6 +713,13 @@ void App::resetInput()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
|
void App::releaseIdleMemory(ULONGLONG now)
|
||||||
|
{
|
||||||
|
expireDiagnostics(now);
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
// Match the Undo eligibility deadline; never discard an available operation.
|
||||||
|
if (!busy && last && now - last->at > 120000) last.reset();
|
||||||
|
}
|
||||||
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||||
{
|
{
|
||||||
@@ -1110,9 +1183,11 @@ void App::transform(int command, int snippet)
|
|||||||
if (!config.autoCorrect || recent.window != target || recent.overflow ||
|
if (!config.autoCorrect || recent.window != target || recent.overflow ||
|
||||||
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
|
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
|
||||||
return;
|
return;
|
||||||
autoCorrector.configure(std::filesystem::path(executable()).parent_path() /
|
{
|
||||||
L"dictionaries",
|
std::lock_guard lock(stateMutex);
|
||||||
config.autoLanguages);
|
if (inputVersion.load() != version) return;
|
||||||
|
autoCorrector.shareDictionaries(autoPredictor);
|
||||||
|
}
|
||||||
std::wstring predicted;
|
std::wstring predicted;
|
||||||
for (wchar_t key : recent.keys)
|
for (wchar_t key : recent.keys)
|
||||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||||
@@ -1263,6 +1338,11 @@ void App::transform(int command, int snippet)
|
|||||||
actual = text;
|
actual = text;
|
||||||
verification = L"selection-mismatch";
|
verification = L"selection-mismatch";
|
||||||
reason = L"previous-result-verification-mismatch";
|
reason = L"previous-result-verification-mismatch";
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
}
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -1310,13 +1390,30 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool sent = false;
|
bool sent = false;
|
||||||
PasteProbe probe(config.writeLog && manual && result != text, text);
|
const bool requireReadback = tx.recoveredSelection();
|
||||||
|
PasteProbe probe(((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
||||||
|
if (requireReadback && result != text && probe.status != L"ready")
|
||||||
|
{
|
||||||
|
reason = L"recovered-selection-unavailable";
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||||
L" changed=" + std::to_wstring(result != text));
|
L" changed=" + std::to_wstring(result != text));
|
||||||
if (result != text)
|
if (result != text)
|
||||||
sent = tx.paste(textClip(result));
|
sent = tx.paste(textClip(result));
|
||||||
else if (automatic && tx.valid())
|
else if (automatic && tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
|
if (requireReadback && result != text && !sent)
|
||||||
|
{
|
||||||
|
reason = L"recovered-selection-paste-cancelled";
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sent || result == text)
|
if (sent || result == text)
|
||||||
autoSelected = false;
|
autoSelected = false;
|
||||||
reason = result == text ? L"conversion-unchanged" :
|
reason = result == text ? L"conversion-unchanged" :
|
||||||
@@ -1326,19 +1423,48 @@ void App::transform(int command, int snippet)
|
|||||||
reason = L"Conversion unchanged";
|
reason = L"Conversion unchanged";
|
||||||
verification = L"calculated-result-equals-original";
|
verification = L"calculated-result-equals-original";
|
||||||
}
|
}
|
||||||
if (sent && manual && config.writeLog)
|
if (sent && ((manual && config.writeLog) || requireReadback))
|
||||||
{
|
{
|
||||||
verification = probe.status;
|
verification = probe.status;
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
{
|
{
|
||||||
auto observed = probe.read((int)caretSteps(result));
|
auto observed = probe.read((int)caretSteps(result));
|
||||||
|
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||||
|
// Retry once only with fresh native proof that the exact original range
|
||||||
|
// is still selected. Never retry an unknown or changed editor state.
|
||||||
|
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
||||||
|
if (tx.paste(textClip(result)) && tx.valid())
|
||||||
|
observed = probe.read((int)caretSteps(result));
|
||||||
|
else
|
||||||
|
observed.reset();
|
||||||
|
}
|
||||||
verification = probe.status;
|
verification = probe.status;
|
||||||
if (observed && tx.valid())
|
if (observed && tx.valid())
|
||||||
{
|
{
|
||||||
actual = *observed;
|
actual = *observed;
|
||||||
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
||||||
|
if (newlines(actual) != newlines(result))
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
diagnosticEvent(L"Paste rejected by readback: previous result cleared; layout not switched");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reason = L"Paste not verified";
|
||||||
|
if (requireReadback)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
last.reset();
|
||||||
|
run.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else reason = L"Paste not verified";
|
|
||||||
}
|
}
|
||||||
else verification = L"context-changed-after-paste";
|
else verification = L"context-changed-after-paste";
|
||||||
}
|
}
|
||||||
@@ -1547,6 +1673,7 @@ void App::trayMenu()
|
|||||||
GetCursorPos(&pt);
|
GetCursorPos(&pt);
|
||||||
pasteTarget = GetForegroundWindow();
|
pasteTarget = GetForegroundWindow();
|
||||||
SetForegroundWindow(window);
|
SetForegroundWindow(window);
|
||||||
|
showMenuAccessKeys(menu);
|
||||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, 0, window, nullptr);
|
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, 0, window, nullptr);
|
||||||
DestroyMenu(menu);
|
DestroyMenu(menu);
|
||||||
PostMessageW(window, WM_NULL, 0, 0);
|
PostMessageW(window, WM_NULL, 0, 0);
|
||||||
@@ -1775,6 +1902,15 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
if (wp == 6)
|
if (wp == 6)
|
||||||
{
|
{
|
||||||
|
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
|
||||||
|
{
|
||||||
|
DWORD code = ERROR_GEN_FAILURE;
|
||||||
|
GetExitCodeProcess(app->notificationRegistration, &code);
|
||||||
|
if (code) log(L"Screenshot notification registration helper failed: " + std::to_wstring(code));
|
||||||
|
CloseHandle(app->notificationRegistration);
|
||||||
|
app->notificationRegistration = nullptr;
|
||||||
|
}
|
||||||
|
app->releaseIdleMemory(GetTickCount64());
|
||||||
app->pollSync();
|
app->pollSync();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1893,7 +2029,7 @@ int App::execute()
|
|||||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
try { registerScreenshotNotifications(); }
|
try { notificationRegistration = startScreenshotNotificationRegistration(); }
|
||||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||||
auto screenshotActivation=startScreenshotActivationListener();
|
auto screenshotActivation=startScreenshotActivationListener();
|
||||||
MSG msg;
|
MSG msg;
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ class App
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
friend struct InputRegressionAccess;
|
friend struct InputRegressionAccess;
|
||||||
|
void releaseIdleMemory(ULONGLONG now);
|
||||||
HHOOK keyboardHook{}, mouseHook{};
|
HHOOK keyboardHook{}, mouseHook{};
|
||||||
std::mutex jobsMutex;
|
std::mutex jobsMutex;
|
||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
std::deque<std::function<void()>> jobs;
|
std::deque<std::function<void()>> jobs;
|
||||||
std::thread worker;
|
std::thread worker;
|
||||||
|
HANDLE notificationRegistration{};
|
||||||
std::thread historySearchWorker;
|
std::thread historySearchWorker;
|
||||||
std::condition_variable historySearchReady;
|
std::condition_variable historySearchReady;
|
||||||
std::function<void()> pendingHistorySearch;
|
std::function<void()> pendingHistorySearch;
|
||||||
|
|||||||
+14
-11
@@ -46,7 +46,7 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
|
|||||||
syncDone.store(true);
|
syncDone.store(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (...) { syncRunning = false; throw; }
|
catch (...) { syncRunning = false; Bytes().swap(syncSnapshot); syncResult = {}; throw; }
|
||||||
return syncStatus;
|
return syncStatus;
|
||||||
}
|
}
|
||||||
void App::pollSync()
|
void App::pollSync()
|
||||||
@@ -57,25 +57,28 @@ void App::pollSync()
|
|||||||
{
|
{
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
syncWorker.join();
|
syncWorker.join();
|
||||||
|
// Own completed payloads locally so every return/exception releases them.
|
||||||
|
auto result = std::exchange(syncResult, SyncResult{});
|
||||||
|
auto snapshot = std::exchange(syncSnapshot, Bytes{});
|
||||||
syncRunning = false;
|
syncRunning = false;
|
||||||
syncDone = false;
|
syncDone = false;
|
||||||
syncStatus = syncResult.status;
|
syncStatus = result.status;
|
||||||
if (syncResult.success)
|
if (result.success)
|
||||||
{
|
{
|
||||||
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != syncSnapshot)
|
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != snapshot)
|
||||||
{
|
{
|
||||||
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
||||||
nextSync = GetTickCount64() + 2000;
|
nextSync = GetTickCount64() + 2000;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
auto next = restoreSyncSettings(result.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
||||||
if (encodeSettings(next) != syncSnapshot)
|
if (encodeSettings(next) != snapshot)
|
||||||
{
|
{
|
||||||
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
|
||||||
applySettings(next);
|
applySettings(next);
|
||||||
}
|
}
|
||||||
saveSyncBase(data, syncConnection, syncResult.base.empty() ? syncResult.settings : syncResult.base);
|
saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base);
|
||||||
pendingSyncConflicts = syncResult.conflicts;
|
pendingSyncConflicts = std::move(result.conflicts);
|
||||||
conflictConnection = syncConnection;
|
conflictConnection = syncConnection;
|
||||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||||
wchar_t stamp[32]{};
|
wchar_t stamp[32]{};
|
||||||
@@ -84,8 +87,8 @@ void App::pollSync()
|
|||||||
}
|
}
|
||||||
// Back off after errors (including bad credentials) instead of repeatedly
|
// Back off after errors (including bad credentials) instead of repeatedly
|
||||||
// hitting userpass lockout. The explicit Sync button remains available.
|
// hitting userpass lockout. The explicit Sync button remains available.
|
||||||
syncRetryAfter = syncResult.success ? 0 : GetTickCount64() + 15 * 60000;
|
syncRetryAfter = result.success ? 0 : GetTickCount64() + 15 * 60000;
|
||||||
nextSync = syncResult.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
nextSync = result.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
||||||
}
|
}
|
||||||
if (!syncRunning && GetTickCount64() >= nextSync)
|
if (!syncRunning && GetTickCount64() >= nextSync)
|
||||||
{
|
{
|
||||||
|
|||||||
+122
-44
@@ -42,82 +42,160 @@ bool safeAutoWord(const std::wstring &word, int minimumLength)
|
|||||||
auto small = lower(word);
|
auto small = lower(word);
|
||||||
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
|
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
|
||||||
}
|
}
|
||||||
std::string_view AutoCorrector::Dictionary::word(size_t index) const
|
namespace {
|
||||||
|
constexpr size_t DictionaryBlockWords = 32;
|
||||||
|
}
|
||||||
|
std::string_view AutoCorrector::Dictionary::word(size_t block) const
|
||||||
{
|
{
|
||||||
auto start = offsets[index];
|
size_t start = offsets[block];
|
||||||
auto end = bytes.find('\t', start);
|
// Every block starts with a complete word (zero shared prefix).
|
||||||
return std::string_view(bytes).substr(start, end - start);
|
size_t length = static_cast<unsigned char>(bytes[start++]) & 15;
|
||||||
|
if (length == 15) length = static_cast<unsigned char>(bytes[start++]);
|
||||||
|
return std::string_view(bytes).substr(start, length);
|
||||||
}
|
}
|
||||||
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
||||||
{
|
{
|
||||||
auto raw = readFile(path, 2 * 1024 * 1024);
|
const auto raw = readFile(path, 2 * 1024 * 1024);
|
||||||
bytes.assign(raw.begin(), raw.end());
|
const std::string_view source(reinterpret_cast<const char *>(raw.data()), raw.size());
|
||||||
for (size_t start = 0; start < bytes.size();)
|
std::vector<uint32_t> lines;
|
||||||
|
lines.reserve(std::count(source.begin(), source.end(), '\n') + 1);
|
||||||
|
for (size_t start = 0; start < source.size();)
|
||||||
{
|
{
|
||||||
auto end = bytes.find('\n', start);
|
auto end = source.find('\n', start);
|
||||||
if (end == bytes.npos)
|
if (end == source.npos) end = source.size();
|
||||||
end = bytes.size();
|
auto tab = source.find('\t', start);
|
||||||
auto tab = bytes.find('\t', start);
|
if (tab == source.npos || tab >= end || tab == start || tab - start > 160)
|
||||||
if (tab == bytes.npos || tab >= end || tab == start || tab - start > 160)
|
|
||||||
throw std::runtime_error("Invalid auto-correction dictionary");
|
throw std::runtime_error("Invalid auto-correction dictionary");
|
||||||
int rank{};
|
int rank{};
|
||||||
auto rankEnd = end > tab + 1 && bytes[end - 1] == '\r' ? end - 1 : end;
|
auto rankEnd = end > tab + 1 && source[end - 1] == '\r' ? end - 1 : end;
|
||||||
auto parse = std::from_chars(bytes.data() + tab + 1, bytes.data() + rankEnd, rank);
|
auto parse = std::from_chars(source.data() + tab + 1, source.data() + rankEnd, rank);
|
||||||
if (parse.ec != std::errc() || parse.ptr != bytes.data() + rankEnd || rank <= 0)
|
if (parse.ec != std::errc() || parse.ptr != source.data() + rankEnd || rank <= 0)
|
||||||
throw std::runtime_error("Invalid dictionary rank");
|
throw std::runtime_error("Invalid dictionary rank");
|
||||||
offsets.push_back((uint32_t)start);
|
lines.push_back(static_cast<uint32_t>(start));
|
||||||
start = end + 1;
|
start = end + 1;
|
||||||
}
|
}
|
||||||
if (offsets.empty())
|
if (lines.empty()) throw std::runtime_error("Empty auto-correction dictionary");
|
||||||
throw std::runtime_error("Empty auto-correction dictionary");
|
auto entry = [&](uint32_t offset) { return source.substr(offset, source.find('\t', offset) - offset); };
|
||||||
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
auto frequency = [&](uint32_t offset) {
|
||||||
auto frequency = [&](uint32_t offset)
|
|
||||||
{
|
|
||||||
int rank{};
|
int rank{};
|
||||||
auto start = offset + entry(offset).size() + 1;
|
auto start = offset + entry(offset).size() + 1;
|
||||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
std::from_chars(source.data() + start, source.data() + source.size(), rank);
|
||||||
return rank;
|
return rank;
|
||||||
};
|
};
|
||||||
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
std::sort(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
|
||||||
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
|
return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b);
|
||||||
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
});
|
||||||
{ return entry(a) == entry(b); }), offsets.end());
|
lines.erase(std::unique(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
|
||||||
offsets.shrink_to_fit();
|
return entry(a) == entry(b);
|
||||||
bytes.shrink_to_fit();
|
}), lines.end());
|
||||||
|
offsets.reserve((lines.size() + DictionaryBlockWords - 1) / DictionaryBlockWords);
|
||||||
|
std::string_view previous;
|
||||||
|
// Allocate the packed payload exactly once. Geometric growth and a final
|
||||||
|
// shrink otherwise leave discarded buffers in the resident process heap.
|
||||||
|
size_t packedSize = 0;
|
||||||
|
for (size_t i = 0; i < lines.size(); ++i)
|
||||||
|
{
|
||||||
|
auto current = entry(lines[i]);
|
||||||
|
size_t prefix = 0;
|
||||||
|
if (i % DictionaryBlockWords != 0)
|
||||||
|
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
|
||||||
|
auto suffix = current.size() - prefix;
|
||||||
|
packedSize += 1 + (prefix >= 15) + (suffix >= 15) + suffix + 2;
|
||||||
|
auto rank = static_cast<uint32_t>(frequency(lines[i]));
|
||||||
|
if (rank >= 65535)
|
||||||
|
do { ++packedSize; rank >>= 7; } while (rank);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
bytes.reserve(packedSize);
|
||||||
|
previous = {};
|
||||||
|
for (size_t i = 0; i < lines.size(); ++i)
|
||||||
|
{
|
||||||
|
auto current = entry(lines[i]);
|
||||||
|
size_t prefix = 0;
|
||||||
|
if (i % DictionaryBlockWords == 0)
|
||||||
|
offsets.push_back(static_cast<uint32_t>(bytes.size()));
|
||||||
|
else
|
||||||
|
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
|
||||||
|
size_t suffix = current.size() - prefix;
|
||||||
|
bytes.push_back(static_cast<char>((std::min(prefix, size_t(15)) << 4) | std::min(suffix, size_t(15))));
|
||||||
|
if (prefix >= 15) bytes.push_back(static_cast<char>(prefix));
|
||||||
|
if (suffix >= 15) bytes.push_back(static_cast<char>(suffix));
|
||||||
|
bytes.append(current.substr(prefix));
|
||||||
|
auto rank = static_cast<uint32_t>(frequency(lines[i]));
|
||||||
|
auto shortRank = std::min(rank, uint32_t(65535));
|
||||||
|
bytes.push_back(static_cast<char>(shortRank & 255));
|
||||||
|
bytes.push_back(static_cast<char>(shortRank >> 8));
|
||||||
|
if (rank >= 65535)
|
||||||
|
do {
|
||||||
|
auto next = rank & 127;
|
||||||
|
rank >>= 7;
|
||||||
|
bytes.push_back(static_cast<char>(next | (rank ? 128 : 0)));
|
||||||
|
} while (rank);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
|
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
|
||||||
{
|
{
|
||||||
auto key = utf8(lower(text));
|
auto key = utf8(lower(text));
|
||||||
size_t begin = 0, end = offsets.size();
|
size_t begin = 0, end = offsets.size();
|
||||||
|
// Find the last block whose first word is <= the search key.
|
||||||
while (begin < end)
|
while (begin < end)
|
||||||
{
|
{
|
||||||
auto mid = begin + (end - begin) / 2;
|
auto mid = begin + (end - begin) / 2;
|
||||||
if (word(mid) < key)
|
if (word(mid) <= key) begin = mid + 1;
|
||||||
begin = mid + 1;
|
else end = mid;
|
||||||
else
|
|
||||||
end = mid;
|
|
||||||
}
|
}
|
||||||
if (begin == offsets.size() || word(begin) != key)
|
if (!begin) return 0;
|
||||||
return 0;
|
size_t block = begin - 1;
|
||||||
auto start = offsets[begin] + word(begin).size() + 1;
|
size_t at = offsets[block];
|
||||||
int rank{};
|
const size_t limit = begin < offsets.size() ? offsets[begin] : bytes.size();
|
||||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
char decoded[160]; // The loader validates lengths; lookup never allocates a decoded word.
|
||||||
return rank;
|
while (at < limit)
|
||||||
|
{
|
||||||
|
auto lengths = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
size_t prefix = lengths >> 4, suffix = lengths & 15;
|
||||||
|
if (prefix == 15) prefix = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
if (suffix == 15) suffix = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
memcpy(decoded + prefix, bytes.data() + at, suffix);
|
||||||
|
at += suffix;
|
||||||
|
uint32_t rank = static_cast<unsigned char>(bytes[at]) |
|
||||||
|
(uint32_t(static_cast<unsigned char>(bytes[at + 1])) << 8);
|
||||||
|
at += 2;
|
||||||
|
if (rank == 65535) {
|
||||||
|
rank = 0;
|
||||||
|
uint32_t shift = 0;
|
||||||
|
unsigned char part;
|
||||||
|
do {
|
||||||
|
part = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
rank |= uint32_t(part & 127) << shift;
|
||||||
|
shift += 7;
|
||||||
|
} while (part & 128);
|
||||||
|
}
|
||||||
|
auto compared = std::string_view(decoded, size_t(prefix) + suffix).compare(key);
|
||||||
|
if (compared == 0) return static_cast<int>(rank);
|
||||||
|
if (compared > 0) return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
|
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
|
||||||
{
|
{
|
||||||
if (languages == wanted)
|
if (languages == wanted)
|
||||||
return;
|
return;
|
||||||
std::map<std::wstring, Dictionary> next;
|
std::map<std::wstring, std::shared_ptr<const Dictionary>> next;
|
||||||
for (auto &lang : wanted)
|
for (auto &lang : wanted)
|
||||||
{
|
{
|
||||||
if (lang != L"en" && lang != L"uk" && lang != L"ru")
|
if (lang != L"en" && lang != L"uk" && lang != L"ru")
|
||||||
throw std::runtime_error("Unsupported dictionary language");
|
throw std::runtime_error("Unsupported dictionary language");
|
||||||
next.emplace(lang, Dictionary(folder / (lang + L".txt")));
|
next.emplace(lang, std::make_shared<const Dictionary>(folder / (lang + L".txt")));
|
||||||
}
|
}
|
||||||
dictionaries = std::move(next);
|
dictionaries = std::move(next);
|
||||||
languages = wanted;
|
languages = wanted;
|
||||||
}
|
}
|
||||||
|
void AutoCorrector::shareDictionaries(const AutoCorrector &source)
|
||||||
|
{
|
||||||
|
dictionaries = source.dictionaries;
|
||||||
|
languages = source.languages;
|
||||||
|
}
|
||||||
void AutoCorrector::reject(std::wstring word, const Settings &settings)
|
void AutoCorrector::reject(std::wstring word, const Settings &settings)
|
||||||
{
|
{
|
||||||
while (!word.empty() && openingDelimiter(word.front(), settings))
|
while (!word.empty() && openingDelimiter(word.front(), settings))
|
||||||
@@ -132,7 +210,7 @@ size_t AutoCorrector::memoryBytes() const
|
|||||||
{
|
{
|
||||||
size_t bytes = 0;
|
size_t bytes = 0;
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
bytes += d.bytes.capacity() + d.offsets.capacity() * sizeof(uint32_t);
|
bytes += d->bytes.capacity() + d->offsets.capacity() * sizeof(uint32_t);
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
|
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
|
||||||
@@ -191,7 +269,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
};
|
};
|
||||||
// Valid words from either selected language are never changed.
|
// Valid words from either selected language are never changed.
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
if (d.rank(word) || personal(language, word))
|
if (d->rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
std::optional<std::wstring> matchedText;
|
std::optional<std::wstring> matchedText;
|
||||||
@@ -211,7 +289,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||||
continue;
|
continue;
|
||||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second->rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (matchedText && *matchedText != converted)
|
if (matchedText && *matchedText != converted)
|
||||||
@@ -233,7 +311,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto consider = [&](const std::wstring &text)
|
auto consider = [&](const std::wstring &text)
|
||||||
{
|
{
|
||||||
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||||||
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
|
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage)->rank(text);
|
||||||
if (!rank) return true;
|
if (!rank) return true;
|
||||||
if (!found.empty()) return false;
|
if (!found.empty()) return false;
|
||||||
found = text;
|
found = text;
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
namespace ks
|
namespace ks
|
||||||
@@ -18,23 +19,30 @@ int matchAutoInput(const Run &, const std::wstring &, const std::vector<Layout>
|
|||||||
bool autoCorrectionAllowed(HWND, const Settings &);
|
bool autoCorrectionAllowed(HWND, const Settings &);
|
||||||
class AutoCorrector
|
class AutoCorrector
|
||||||
{
|
{
|
||||||
|
friend struct AutoCorrectorRegressionAccess;
|
||||||
struct Dictionary
|
struct Dictionary
|
||||||
{
|
{
|
||||||
|
// Front-coded UTF-8 words in independently searchable blocks of 32.
|
||||||
|
// Prefix/suffix lengths share a byte (15 escapes to a full byte length).
|
||||||
|
// Ranks use two bytes; 65535 escapes to a varint for larger custom ranks.
|
||||||
std::string bytes;
|
std::string bytes;
|
||||||
std::vector<uint32_t> offsets;
|
std::vector<uint32_t> offsets;
|
||||||
std::string_view word(size_t) const;
|
std::string_view word(size_t) const;
|
||||||
explicit Dictionary(const std::filesystem::path &);
|
explicit Dictionary(const std::filesystem::path &);
|
||||||
int rank(const std::wstring &) const;
|
int rank(const std::wstring &) const;
|
||||||
};
|
};
|
||||||
std::map<std::wstring, Dictionary> dictionaries;
|
std::map<std::wstring, std::shared_ptr<const Dictionary>> dictionaries;
|
||||||
std::vector<std::wstring> languages;
|
std::vector<std::wstring> languages;
|
||||||
std::set<std::wstring> rejected;
|
std::set<std::wstring> rejected;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
|
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
|
||||||
|
// Share immutable dictionary storage, keeping each consumer's undo rejections private.
|
||||||
|
void shareDictionaries(const AutoCorrector &);
|
||||||
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
|
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
|
||||||
const Settings &) const;
|
const Settings &) const;
|
||||||
void reject(std::wstring word, const Settings & = {});
|
void reject(std::wstring word, const Settings & = {});
|
||||||
|
// Referenced dictionary payload, including shared storage (do not sum across consumers).
|
||||||
size_t memoryBytes() const;
|
size_t memoryBytes() const;
|
||||||
};
|
};
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
+17
-3
@@ -4,14 +4,28 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
|
inline constexpr size_t HistoryThumbnailBudget = 1024 * 1024;
|
||||||
|
inline constexpr size_t HistoryRichTextBudget = 256 * 1024;
|
||||||
// Keep the visible working set even in unusually tall windows. Never evict a
|
// 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.
|
// visible preview to admit another visible preview: that creates a paint loop.
|
||||||
|
template<class T, class Weight>
|
||||||
|
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||||
|
const std::set<std::wstring> &visible, size_t limit,
|
||||||
|
size_t byteLimit, Weight weight)
|
||||||
|
{
|
||||||
|
size_t bytes = 0;
|
||||||
|
for (const auto &[id, value] : cache) bytes += weight(value);
|
||||||
|
for(auto it=cache.begin(); (cache.size()>limit || bytes>byteLimit) && it!=cache.end();)
|
||||||
|
if(!visible.contains(it->first)) {
|
||||||
|
bytes -= weight(it->second);
|
||||||
|
it=cache.erase(it);
|
||||||
|
}
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
template<class T>
|
template<class T>
|
||||||
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||||
const std::set<std::wstring> &visible, size_t limit=32)
|
const std::set<std::wstring> &visible, size_t limit=32)
|
||||||
{
|
{
|
||||||
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
trimHistoryPreviews(cache, visible, limit, 0, [](const T &) { return size_t{}; });
|
||||||
if(!visible.contains(it->first)) it=cache.erase(it);
|
|
||||||
else ++it;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -123,13 +123,16 @@ struct OcrStatics : IInspectable
|
|||||||
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
||||||
{
|
{
|
||||||
Bytes encoded;
|
Bytes encoded;
|
||||||
|
const Bytes *input = &encoded;
|
||||||
for (const auto &f : clip.formats)
|
for (const auto &f : clip.formats)
|
||||||
if (f.name == L"PNG")
|
if (f.name == L"PNG")
|
||||||
{
|
{
|
||||||
encoded = f.data;
|
input = &f.data;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (encoded.empty())
|
if (input->empty())
|
||||||
|
{
|
||||||
|
input = &encoded;
|
||||||
for (const auto &f : clip.formats)
|
for (const auto &f : clip.formats)
|
||||||
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
|
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
|
||||||
{
|
{
|
||||||
@@ -146,19 +149,21 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
|||||||
file.bfType = 0x4d42;
|
file.bfType = 0x4d42;
|
||||||
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
|
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
|
||||||
file.bfOffBits = (DWORD)offset;
|
file.bfOffBits = (DWORD)offset;
|
||||||
encoded.resize(sizeof(file));
|
encoded.resize(sizeof(file) + f.data.size());
|
||||||
memcpy(encoded.data(), &file, sizeof(file));
|
memcpy(encoded.data(), &file, sizeof(file));
|
||||||
encoded.insert(encoded.end(), f.data.begin(), f.data.end());
|
memcpy(encoded.data() + sizeof(file), f.data.data(), f.data.size());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (encoded.empty())
|
}
|
||||||
|
if (input->empty())
|
||||||
throw std::runtime_error("This clipboard item has no supported image");
|
throw std::runtime_error("This clipboard item has no supported image");
|
||||||
Com<IWICImagingFactory> wic;
|
Com<IWICImagingFactory> wic;
|
||||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
|
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
|
||||||
(void **)wic.out()));
|
(void **)wic.out()));
|
||||||
Com<IWICStream> stream;
|
Com<IWICStream> stream;
|
||||||
check(wic->CreateStream(stream.out()));
|
check(wic->CreateStream(stream.out()));
|
||||||
check(stream->InitializeFromMemory(encoded.data(), (DWORD)encoded.size()));
|
// WIC decoding only reads the stream. The clip outlives every WIC object here.
|
||||||
|
check(stream->InitializeFromMemory(const_cast<BYTE *>(input->data()), (DWORD)input->size()));
|
||||||
Com<IWICBitmapDecoder> decoder;
|
Com<IWICBitmapDecoder> decoder;
|
||||||
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
|
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
|
||||||
Com<IWICBitmapFrameDecode> frame;
|
Com<IWICBitmapFrameDecode> frame;
|
||||||
|
|||||||
+18
-1
@@ -13,7 +13,7 @@ class PasteProbe
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
std::wstring status = L"not-requested";
|
std::wstring status = L"not-requested";
|
||||||
PasteProbe(bool enabled, const std::wstring &original)
|
PasteProbe(bool enabled, HWND target)
|
||||||
{
|
{
|
||||||
if (!enabled)
|
if (!enabled)
|
||||||
return;
|
return;
|
||||||
@@ -25,6 +25,18 @@ class PasteProbe
|
|||||||
automation->put_TransactionTimeout(200);
|
automation->put_TransactionTimeout(200);
|
||||||
if (FAILED(automation->GetFocusedElement(&element)) || !element)
|
if (FAILED(automation->GetFocusedElement(&element)) || !element)
|
||||||
return;
|
return;
|
||||||
|
if (target)
|
||||||
|
{
|
||||||
|
DWORD process{};
|
||||||
|
GetWindowThreadProcessId(target, &process);
|
||||||
|
int focusedProcess{};
|
||||||
|
if (!process || FAILED(element->get_CurrentProcessId(&focusedProcess)) ||
|
||||||
|
process != static_cast<DWORD>(focusedProcess) || GetForegroundWindow() != target)
|
||||||
|
{
|
||||||
|
status = L"focused-control-changed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
BOOL password = TRUE;
|
BOOL password = TRUE;
|
||||||
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
|
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
|
||||||
{
|
{
|
||||||
@@ -35,6 +47,11 @@ class PasteProbe
|
|||||||
(void **)&pattern)) ||
|
(void **)&pattern)) ||
|
||||||
!pattern)
|
!pattern)
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
PasteProbe(bool enabled, const std::wstring &original, HWND target = nullptr)
|
||||||
|
: PasteProbe(enabled, target)
|
||||||
|
{
|
||||||
|
if (!pattern) return;
|
||||||
auto selected = read(1, false);
|
auto selected = read(1, false);
|
||||||
auto normalize = [](std::wstring value)
|
auto normalize = [](std::wstring value)
|
||||||
{
|
{
|
||||||
|
|||||||
+42
-2
@@ -159,6 +159,31 @@ void showRequestedWindow(HWND window)
|
|||||||
if (!IsWindowVisible(window))
|
if (!IsWindowVisible(window))
|
||||||
ShowWindow(window, SW_SHOW);
|
ShowWindow(window, SW_SHOW);
|
||||||
}
|
}
|
||||||
|
void showMenuAccessKeys(HMENU menu)
|
||||||
|
{
|
||||||
|
// Mouse-opened popups ignore the owner's keyboard-cue state.
|
||||||
|
// Preserve the mnemonic and expose it in the right-hand hint column.
|
||||||
|
for (int i = 0; i < GetMenuItemCount(menu); ++i)
|
||||||
|
{
|
||||||
|
if (auto child = GetSubMenu(menu, i)) showMenuAccessKeys(child);
|
||||||
|
int length = GetMenuStringW(menu, i, nullptr, 0, MF_BYPOSITION);
|
||||||
|
std::wstring text(length + 1, L'\0');
|
||||||
|
GetMenuStringW(menu, i, text.data(), (int)text.size(), MF_BYPOSITION);
|
||||||
|
text.resize(length);
|
||||||
|
if (text.find(L'\t') != text.npos) continue;
|
||||||
|
for (size_t p = 0; p + 1 < text.size(); ++p)
|
||||||
|
{
|
||||||
|
if (text[p] != L'&') continue;
|
||||||
|
if (text[p + 1] == L'&') { ++p; continue; }
|
||||||
|
text += L"\t" + upper(text.substr(p + 1, 1));
|
||||||
|
MENUITEMINFOW item{sizeof(item)};
|
||||||
|
item.fMask = MIIM_STRING;
|
||||||
|
item.dwTypeData = text.data();
|
||||||
|
SetMenuItemInfoW(menu, i, TRUE, &item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
bool activateWindow(HWND window)
|
bool activateWindow(HWND window)
|
||||||
{
|
{
|
||||||
if (!IsWindow(window))
|
if (!IsWindow(window))
|
||||||
@@ -579,10 +604,25 @@ std::recursive_mutex logGate;
|
|||||||
bool loggingEnabled = false;
|
bool loggingEnabled = false;
|
||||||
DiagnosticBuffer diagnosticBuffer;
|
DiagnosticBuffer diagnosticBuffer;
|
||||||
}
|
}
|
||||||
|
size_t DiagnosticBuffer::expire(ULONGLONG now)
|
||||||
|
{
|
||||||
|
size_t removed = 0;
|
||||||
|
while (!entries.empty() && now - entries.front().first > 30000)
|
||||||
|
{
|
||||||
|
entries.pop_front();
|
||||||
|
++removed;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
void expireDiagnostics(ULONGLONG now)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(logGate);
|
||||||
|
diagnosticBuffer.expire(now);
|
||||||
|
}
|
||||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
||||||
{
|
{
|
||||||
while (!entries.empty() && (entries.size() >= 128 || now - entries.front().first > 30000))
|
expire(now);
|
||||||
entries.pop_front();
|
while (entries.size() >= 128) entries.pop_front();
|
||||||
entries.emplace_back(now, message);
|
entries.emplace_back(now, message);
|
||||||
if (!problem)
|
if (!problem)
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ HKL windowLayout(HWND);
|
|||||||
bool switchWindowLayout(HWND, HKL, const std::function<bool()> &valid);
|
bool switchWindowLayout(HWND, HKL, const std::function<bool()> &valid);
|
||||||
bool activateWindow(HWND);
|
bool activateWindow(HWND);
|
||||||
void showRequestedWindow(HWND);
|
void showRequestedWindow(HWND);
|
||||||
|
void showMenuAccessKeys(HMENU);
|
||||||
bool ownsClipboard(HWND);
|
bool ownsClipboard(HWND);
|
||||||
bool webViewDescendant(DWORD owner, DWORD host,
|
bool webViewDescendant(DWORD owner, DWORD host,
|
||||||
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
|
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
|
||||||
@@ -71,10 +72,12 @@ class DiagnosticBuffer
|
|||||||
{
|
{
|
||||||
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
|
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
|
||||||
public:
|
public:
|
||||||
|
size_t expire(ULONGLONG now);
|
||||||
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
|
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
|
||||||
void clear() { entries.clear(); }
|
void clear() { entries.clear(); }
|
||||||
};
|
};
|
||||||
void setLoggingEnabled(bool enabled);
|
void setLoggingEnabled(bool enabled);
|
||||||
|
void expireDiagnostics(ULONGLONG now);
|
||||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||||
void diagnosticOperation(const std::string &json);
|
void diagnosticOperation(const std::string &json);
|
||||||
std::wstring readDiagnosticLog(bool previous);
|
std::wstring readDiagnosticLog(bool previous);
|
||||||
|
|||||||
+5
-4
@@ -783,12 +783,13 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
|
|||||||
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
|
||||||
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
|
||||||
else if(hit.second>=MarkPart::Vertex0 && hit.second<=MarkPart::Vertex2) cursor=IDC_SIZEALL;
|
else if(hit.second>=MarkPart::Vertex0 && hit.second<=MarkPart::Vertex2) cursor=IDC_SIZEALL;
|
||||||
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
|
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Radius)
|
||||||
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
|
cursor=IDC_SIZEALL;
|
||||||
|
else if(hit.second==MarkPart::Thickness) {
|
||||||
const auto &mark=c.marks[hit.first];
|
const auto &mark=c.marks[hit.first];
|
||||||
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
|
auto [from,to]=thicknessSegment(mark);
|
||||||
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
|
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
|
||||||
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
|
angle+=3.141592653589793/2;
|
||||||
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
|
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
|
||||||
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
|
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
|
||||||
cursor=cursors[axis];
|
cursor=cursors[axis];
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ void registerScreenshotNotifications()
|
|||||||
// Notify the shell that notification identity/activation registration changed.
|
// Notify the shell that notification identity/activation registration changed.
|
||||||
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||||
}
|
}
|
||||||
|
HANDLE startScreenshotNotificationRegistration()
|
||||||
|
{
|
||||||
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
auto command=L"\""+path.wstring()+L"\" --register-notifications";
|
||||||
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
|
startup.dwFlags=STARTF_USESHOWWINDOW; startup.wShowWindow=SW_HIDE;
|
||||||
|
PROCESS_INFORMATION process{};
|
||||||
|
if(!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW,
|
||||||
|
nullptr,path.parent_path().c_str(),&startup,&process))
|
||||||
|
throw std::runtime_error(utf8(lastError(L"Cannot start notification registration helper")));
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
return process.hProcess;
|
||||||
|
}
|
||||||
|
void registerScreenshotNotificationsInHelper()
|
||||||
|
{
|
||||||
|
auto process=startScreenshotNotificationRegistration();
|
||||||
|
auto wait=WaitForSingleObject(process,10000);
|
||||||
|
DWORD code=ERROR_GEN_FAILURE;
|
||||||
|
bool completed=wait==WAIT_OBJECT_0 && GetExitCodeProcess(process,&code);
|
||||||
|
CloseHandle(process);
|
||||||
|
if(!completed || code)
|
||||||
|
throw std::runtime_error(completed ? "Notification registration helper failed: "+std::to_string(code)
|
||||||
|
: "Notification registration helper did not complete within 10 seconds");
|
||||||
|
}
|
||||||
bool validScreenshotToken(const std::wstring &id)
|
bool validScreenshotToken(const std::wstring &id)
|
||||||
{
|
{
|
||||||
if(id.size()!=36) return false;
|
if(id.size()!=36) return false;
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ bool notifyUpdateAvailable(const std::wstring &version)
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
||||||
registerScreenshotNotifications();
|
registerScreenshotNotificationsInHelper();
|
||||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ namespace ks {
|
|||||||
bool notifyScreenshot(const Clip &);
|
bool notifyScreenshot(const Clip &);
|
||||||
std::string screenshotNotificationError();
|
std::string screenshotNotificationError();
|
||||||
void registerScreenshotNotifications();
|
void registerScreenshotNotifications();
|
||||||
|
// Caller owns the returned process handle; the helper exits after registration.
|
||||||
|
HANDLE startScreenshotNotificationRegistration();
|
||||||
|
// For background notification workers only; bounded wait, never on the UI thread.
|
||||||
|
void registerScreenshotNotificationsInHelper();
|
||||||
int runScreenshotToastActivator();
|
int runScreenshotToastActivator();
|
||||||
DWORD startScreenshotActivationListener();
|
DWORD startScreenshotActivationListener();
|
||||||
bool validScreenshotToken(const std::wstring &);
|
bool validScreenshotToken(const std::wstring &);
|
||||||
|
|||||||
+63
-5
@@ -479,12 +479,14 @@ struct HistoryView
|
|||||||
{
|
{
|
||||||
App *app{};
|
App *app{};
|
||||||
std::vector<Entry> items;
|
std::vector<Entry> items;
|
||||||
|
std::wstring rememberedSelection, rememberedTop;
|
||||||
uint64_t generation{};
|
uint64_t generation{};
|
||||||
std::map<std::wstring, Pixels> thumbnails;
|
std::map<std::wstring, Pixels> thumbnails;
|
||||||
std::map<std::wstring, std::string> richTexts;
|
std::map<std::wstring, std::string> richTexts;
|
||||||
HWND rich{};
|
HWND rich{};
|
||||||
std::set<std::wstring> pending;
|
std::set<std::wstring> pending;
|
||||||
bool scrolling{};
|
bool scrolling{};
|
||||||
|
bool closed{};
|
||||||
};
|
};
|
||||||
bool selectedImage(HWND w, HistoryView *v)
|
bool selectedImage(HWND w, HistoryView *v)
|
||||||
{
|
{
|
||||||
@@ -502,6 +504,7 @@ std::wstring selected(HWND w, HistoryView *v)
|
|||||||
}
|
}
|
||||||
void search(HWND w, HistoryView *v)
|
void search(HWND w, HistoryView *v)
|
||||||
{
|
{
|
||||||
|
v->closed = false;
|
||||||
auto q = value(w, 101);
|
auto q = value(w, 101);
|
||||||
auto generation = ++v->generation;
|
auto generation = ++v->generation;
|
||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
@@ -770,6 +773,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
KillTimer(w, 1);
|
KillTimer(w, 1);
|
||||||
|
if (v->closed) return 0;
|
||||||
search(w, v);
|
search(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
case ResultsMessage:
|
case ResultsMessage:
|
||||||
@@ -778,8 +782,10 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (result->generation != v->generation)
|
if (result->generation != v->generation)
|
||||||
return 0;
|
return 0;
|
||||||
auto old = selected(w, v);
|
auto old = selected(w, v);
|
||||||
|
if (old.empty()) old = v->rememberedSelection;
|
||||||
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||||
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
||||||
|
if (topId.empty()) topId = v->rememberedTop;
|
||||||
v->items = std::move(result->items);
|
v->items = std::move(result->items);
|
||||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
||||||
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
||||||
@@ -823,8 +829,10 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->richTexts[result->id] = std::move(result->rtf);
|
v->richTexts[result->id] = std::move(result->rtf);
|
||||||
else
|
else
|
||||||
v->thumbnails[result->id] = std::move(result->pixels);
|
v->thumbnails[result->id] = std::move(result->pixels);
|
||||||
trimHistoryPreviews(v->thumbnails,visible);
|
trimHistoryPreviews(v->thumbnails,visible,32,HistoryThumbnailBudget,
|
||||||
trimHistoryPreviews(v->richTexts,visible);
|
[](const Pixels &p) { return p.bgra.capacity(); });
|
||||||
|
trimHistoryPreviews(v->richTexts,visible,32,HistoryRichTextBudget,
|
||||||
|
[](const std::string &s) { return s.capacity(); });
|
||||||
if(repaint) InvalidateRect(list,&changed,FALSE);
|
if(repaint) InvalidateRect(list,&changed,FALSE);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -867,6 +875,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
AppendMenuW(menu, MF_STRING, 113, L"&Delete");
|
AppendMenuW(menu, MF_STRING, 113, L"&Delete");
|
||||||
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"&Edit image in screenshot editor":L"&Open full item");
|
AppendMenuW(menu, MF_STRING, 114, selectedImage(w,v)?L"&Edit image in screenshot editor":L"&Open full item");
|
||||||
AppendMenuW(menu, MF_STRING, 115, L"&Copy text from image (OCR)");
|
AppendMenuW(menu, MF_STRING, 115, L"&Copy text from image (OCR)");
|
||||||
|
showMenuAccessKeys(menu);
|
||||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
|
||||||
DestroyMenu(menu);
|
DestroyMenu(menu);
|
||||||
if (cmd)
|
if (cmd)
|
||||||
@@ -874,14 +883,23 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_CLOSE:
|
case WM_CLOSE:
|
||||||
|
{
|
||||||
|
v->rememberedSelection = selected(w, v);
|
||||||
|
int top = (int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||||
|
v->rememberedTop = top >= 0 && top < (int)v->items.size() ? v->items[top].id : L"";
|
||||||
|
KillTimer(w, 1);
|
||||||
|
v->closed = true;
|
||||||
|
++v->generation; // Discard search/preview results queued before closing.
|
||||||
ShowWindow(w, SW_HIDE);
|
ShowWindow(w, SW_HIDE);
|
||||||
|
SendDlgItemMessageW(w,102,LB_RESETCONTENT,0,0);
|
||||||
|
std::vector<Entry>().swap(v->items);
|
||||||
v->thumbnails.clear();
|
v->thumbnails.clear();
|
||||||
v->richTexts.clear();
|
v->richTexts.clear();
|
||||||
if (v->rich)
|
if (v->rich)
|
||||||
SetWindowTextW(v->rich, L"");
|
SetWindowTextW(v->rich, L"");
|
||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
++v->generation;
|
|
||||||
return 0;
|
return 0;
|
||||||
|
}
|
||||||
case WM_NCDESTROY:
|
case WM_NCDESTROY:
|
||||||
disposeFont(w);
|
disposeFont(w);
|
||||||
v->app->historyWindow = nullptr;
|
v->app->historyWindow = nullptr;
|
||||||
@@ -1338,6 +1356,46 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
|||||||
showRequestedWindow(w); SetForegroundWindow(w);
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
}
|
}
|
||||||
#include "ui_sync.inl"
|
#include "ui_sync.inl"
|
||||||
|
bool settingsChanged(HWND w, SettingsView *v)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return encodeSettings(collectSettings(w, v)) != encodeSettings(v->app->settings);
|
||||||
|
}
|
||||||
|
catch (const std::exception &)
|
||||||
|
{
|
||||||
|
// Invalid edits are unsaved changes too. Allow discarding them without
|
||||||
|
// requiring the user to repair the fields first.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void closeSettings(HWND w, SettingsView *v)
|
||||||
|
{
|
||||||
|
if (settingsChanged(w, v))
|
||||||
|
{
|
||||||
|
int choice = MessageBoxW(w,
|
||||||
|
L"You have unsaved settings changes. Save them before closing?\n\n"
|
||||||
|
L"Yes: save and close.\nNo: discard changes.\nCancel: keep editing.",
|
||||||
|
L"KSwitcher — Unsaved settings", MB_YESNOCANCEL | MB_ICONWARNING | MB_DEFBUTTON3);
|
||||||
|
if (choice != IDYES && choice != IDNO)
|
||||||
|
return;
|
||||||
|
if (choice == IDYES)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (v->app->busy)
|
||||||
|
throw std::runtime_error("Wait for the current text operation to finish");
|
||||||
|
v->app->applySettings(collectSettings(w, v));
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
error(w, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DestroyWindow(w);
|
||||||
|
}
|
||||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (msg == WM_DPICHANGED)
|
if (msg == WM_DPICHANGED)
|
||||||
@@ -1403,7 +1461,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == IDCANCEL)
|
if (id == IDCANCEL)
|
||||||
{
|
{
|
||||||
DestroyWindow(w);
|
closeSettings(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 250)
|
if (id == 250)
|
||||||
@@ -1454,7 +1512,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case WM_CLOSE:
|
case WM_CLOSE:
|
||||||
DestroyWindow(w);
|
closeSettings(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
case WM_NCDESTROY:
|
case WM_NCDESTROY:
|
||||||
disposeFont(w);
|
disposeFont(w);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.29";
|
inline constexpr wchar_t AppVersion[] = L"0.6.36";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#include "autocorrect.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <psapi.h>
|
||||||
|
#include <random>
|
||||||
|
namespace ks {
|
||||||
|
struct AutoCorrectorRegressionAccess {
|
||||||
|
static int rank(const AutoCorrector &c,const std::wstring &lang,const std::wstring &word) {
|
||||||
|
return c.dictionaries.at(lang)->rank(word);
|
||||||
|
}
|
||||||
|
static std::vector<int> ranks(const std::filesystem::path &path,const std::vector<std::wstring> &words) {
|
||||||
|
AutoCorrector::Dictionary d(path);
|
||||||
|
std::vector<int> values;for(const auto &word:words)values.push_back(d.rank(word));return values;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int main(int argc,char **argv) {
|
||||||
|
try {
|
||||||
|
auto start=std::chrono::steady_clock::now();
|
||||||
|
ks::AutoCorrector c;c.configure(L"dictionaries",{L"en",L"uk",L"ru"});
|
||||||
|
PROCESS_MEMORY_COUNTERS_EX m{};
|
||||||
|
GetProcessMemoryInfo(GetCurrentProcess(),reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&m),sizeof(m));
|
||||||
|
std::cout<<"dictionary_bytes="<<c.memoryBytes()<<" private_bytes="<<m.PrivateUsage
|
||||||
|
<<" load_ms="<<std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count()<<'\n';
|
||||||
|
struct Query {std::wstring language,word; int rank;};
|
||||||
|
std::vector<Query> queries;
|
||||||
|
for(const auto *lang:{L"en",L"uk",L"ru"}) {
|
||||||
|
std::ifstream input(std::filesystem::path(L"dictionaries")/(std::wstring(lang)+L".txt"));
|
||||||
|
std::map<std::wstring,int> expected;
|
||||||
|
for(std::string line;std::getline(input,line);) {
|
||||||
|
auto tab=line.find('\t');auto word=ks::wide(line.substr(0,tab));auto rank=std::stoi(line.substr(tab+1));
|
||||||
|
auto [it,added]=expected.emplace(word,rank);if(!added)it->second=std::min(it->second,rank);
|
||||||
|
}
|
||||||
|
if(expected.empty())throw std::runtime_error("Missing benchmark dictionary");
|
||||||
|
for(auto &[word,rank]:expected) {
|
||||||
|
if(ks::AutoCorrectorRegressionAccess::rank(c,lang,word)!=rank)throw std::runtime_error("Dictionary rank differs from source");
|
||||||
|
if(ks::AutoCorrectorRegressionAccess::rank(c,lang,word+L"~")!=0)throw std::runtime_error("False dictionary hit");
|
||||||
|
queries.push_back({lang,word,rank});queries.push_back({lang,word+L"~",0});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::mt19937 random(42);std::shuffle(queries.begin(),queries.end(),random);
|
||||||
|
uint64_t checksum=0,expected=0;
|
||||||
|
for(auto &q:queries)expected+=q.rank;
|
||||||
|
start=std::chrono::steady_clock::now();
|
||||||
|
for(int pass=0;pass<5;++pass)for(auto &q:queries)checksum+=ks::AutoCorrectorRegressionAccess::rank(c,q.language,q.word);
|
||||||
|
auto ms=std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count();
|
||||||
|
if(checksum!=expected*5)throw std::runtime_error("Lookup checksum mismatch");
|
||||||
|
std::cout<<"verified_words="<<queries.size()/2<<" queries="<<queries.size()*5<<" lookup_ms="<<ms
|
||||||
|
<<" ns_per_lookup="<<ms*1000000/(queries.size()*5)<<" checksum="<<checksum<<'\n';
|
||||||
|
auto fixture=std::filesystem::temp_directory_path()/(L"KSwitcher-dictionary-"+
|
||||||
|
std::to_wstring(GetCurrentProcessId())+L"-"+std::to_wstring(GetTickCount64())+L".txt");
|
||||||
|
struct RemoveFixture { std::filesystem::path path; ~RemoveFixture(){std::error_code ec;std::filesystem::remove(path,ec);} } cleanup{fixture};
|
||||||
|
auto put=[&](const std::string &text){ks::atomicWrite(fixture,ks::Bytes(text.begin(),text.end()));};
|
||||||
|
std::string text="duplicate\t9\r\nduplicate\t2\n";
|
||||||
|
std::vector<std::wstring> words;
|
||||||
|
std::vector<int> expectedRanks;
|
||||||
|
for(int i=129;i>=0;--i) {
|
||||||
|
auto word=L"prefix_"+std::to_wstring(i);
|
||||||
|
text+=ks::utf8(word)+"\t"+std::to_string(i+1)+"\n";
|
||||||
|
words.push_back(word);expectedRanks.push_back(i+1);
|
||||||
|
}
|
||||||
|
text+=std::string(160,'x')+"\t2147483647"; // Maximum length/rank, no final newline.
|
||||||
|
words.insert(words.end(),{std::wstring(160,L'x'),L"DUPLICATE",L"",L"prefix_",L"zzzz"});
|
||||||
|
expectedRanks.insert(expectedRanks.end(),{2147483647,2,0,0,0});
|
||||||
|
for(auto rank:{65534,65535,65536}) {
|
||||||
|
auto word=std::wstring(159,L'x')+wchar_t(L'y'+rank-65534);
|
||||||
|
text+="\n"+ks::utf8(word)+"\t"+std::to_string(rank);
|
||||||
|
words.push_back(word);expectedRanks.push_back(rank);
|
||||||
|
}
|
||||||
|
put(text);
|
||||||
|
if(ks::AutoCorrectorRegressionAccess::ranks(fixture,words)!=expectedRanks)
|
||||||
|
throw std::runtime_error("Block boundaries, duplicate ranks or maximum values failed");
|
||||||
|
for(const auto &invalid:std::vector<std::string>{"","missing-tab","x\t0","x\t-1","x\t2147483648",std::string(161,'x')+"\t1","\t1","x\t1bad"}) {
|
||||||
|
put(invalid);bool rejected=false;
|
||||||
|
try {ks::AutoCorrectorRegressionAccess::ranks(fixture,{L"x"});}catch(const std::runtime_error &){rejected=true;}
|
||||||
|
if(!rejected)throw std::runtime_error("Invalid dictionary was accepted");
|
||||||
|
}
|
||||||
|
const bool baseline=argc==2 && std::string(argv[1])=="--baseline";
|
||||||
|
if(!baseline && c.memoryBytes()>=1536*1024)throw std::runtime_error("Bundled dictionaries exceed compact memory budget");
|
||||||
|
std::cout<<"Passed complete dictionary equivalence and format regressions\n";
|
||||||
|
return 0;
|
||||||
|
}catch(const std::exception&e){std::cerr<<e.what()<<'\n';return 1;}
|
||||||
|
}
|
||||||
+139
@@ -5,6 +5,19 @@
|
|||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
static int settingsCloseChoice = IDCANCEL;
|
||||||
|
static int settingsClosePrompts = 0;
|
||||||
|
static void CALLBACK answerSettingsClose(HWND, UINT, UINT_PTR, DWORD)
|
||||||
|
{
|
||||||
|
HWND dialog = FindWindowW(L"#32770", L"KSwitcher — Unsaved settings");
|
||||||
|
DWORD process{};
|
||||||
|
if (dialog && GetWindowThreadProcessId(dialog, &process) == GetCurrentThreadId() &&
|
||||||
|
process == GetCurrentProcessId())
|
||||||
|
{
|
||||||
|
++settingsClosePrompts;
|
||||||
|
SendMessageW(dialog, WM_COMMAND, settingsCloseChoice, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
|
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
|
||||||
{
|
{
|
||||||
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
|
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
|
||||||
@@ -12,6 +25,38 @@ LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, U
|
|||||||
}
|
}
|
||||||
namespace ks {
|
namespace ks {
|
||||||
struct InputRegressionAccess {
|
struct InputRegressionAccess {
|
||||||
|
static bool idleUndo(App &a) {
|
||||||
|
a.last=LastTransform{}; a.last->at=100;
|
||||||
|
a.releaseIdleMemory(120100);
|
||||||
|
if (!a.last) return false;
|
||||||
|
a.busy=true;
|
||||||
|
a.releaseIdleMemory(120101);
|
||||||
|
if (!a.last) { a.busy=false; return false; }
|
||||||
|
a.busy=false;
|
||||||
|
a.releaseIdleMemory(120101);
|
||||||
|
return !a.last;
|
||||||
|
}
|
||||||
|
static bool releasedSync(const App &a) {
|
||||||
|
return a.syncSnapshot.capacity()==0 && a.syncResult.settings.capacity()==0 &&
|
||||||
|
a.syncResult.base.capacity()==0 && a.syncResult.conflicts.capacity()==0;
|
||||||
|
}
|
||||||
|
static bool completedSync(App &a, int mode) {
|
||||||
|
sampleConflicts(a);
|
||||||
|
auto conflicts=encodeSyncConflicts(a.pendingSyncConflicts);
|
||||||
|
a.syncConnection=loadSyncConfig(a.data);
|
||||||
|
a.syncSnapshot=encodeSettings(a.settings);
|
||||||
|
a.syncResult={mode!=1,sharedSyncSettings(a.settings),2,L"test"};
|
||||||
|
a.syncResult.base=a.syncResult.settings;
|
||||||
|
a.syncResult.conflicts=a.pendingSyncConflicts;
|
||||||
|
if(mode==2) a.syncResult.settings={1,2,3}; // Decode exception after joining.
|
||||||
|
a.syncWorker=std::thread([]{});
|
||||||
|
a.syncRunning=true; a.syncDone=true; a.busy=true;
|
||||||
|
a.pollSync();
|
||||||
|
bool retained=!a.syncSnapshot.empty() && a.syncRunning;
|
||||||
|
a.busy=false; a.pollSync();
|
||||||
|
return retained && releasedSync(a) && !a.syncRunning &&
|
||||||
|
encodeSyncConflicts(a.pendingSyncConflicts)==conflicts;
|
||||||
|
}
|
||||||
static inline bool delivery = true;
|
static inline bool delivery = true;
|
||||||
static inline int notifications = 0;
|
static inline int notifications = 0;
|
||||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||||
@@ -31,6 +76,7 @@ struct InputRegressionAccess {
|
|||||||
a.syncRunning=true; a.syncDone=true;
|
a.syncRunning=true; a.syncDone=true;
|
||||||
a.pollSync();
|
a.pollSync();
|
||||||
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
||||||
|
releasedSync(a) &&
|
||||||
!std::filesystem::exists(a.data/L"sync-base.bin");
|
!std::filesystem::exists(a.data/L"sync-base.bin");
|
||||||
}
|
}
|
||||||
static bool syncBackoff(App &a) {
|
static bool syncBackoff(App &a) {
|
||||||
@@ -80,6 +126,25 @@ int main(int argc, char **argv) {
|
|||||||
try {
|
try {
|
||||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||||
InitCommonControlsEx(&controls);
|
InitCommonControlsEx(&controls);
|
||||||
|
{
|
||||||
|
HMENU menu = CreatePopupMenu(), child = CreatePopupMenu();
|
||||||
|
AppendMenuW(menu,MF_STRING | MF_CHECKED,1,L"&Settings…");
|
||||||
|
AppendMenuW(menu,MF_STRING,2,L"Copy\tCtrl+C");
|
||||||
|
AppendMenuW(child,MF_STRING,3,L"Rock && &Roll");
|
||||||
|
AppendMenuW(menu,MF_POPUP,(UINT_PTR)child,L"&Pause");
|
||||||
|
ks::showMenuAccessKeys(menu);
|
||||||
|
ks::showMenuAccessKeys(menu);
|
||||||
|
wchar_t label[128]{};
|
||||||
|
GetMenuStringW(menu,1,label,128,MF_BYCOMMAND);
|
||||||
|
require(std::wstring(label)==L"&Settings…\tS" &&
|
||||||
|
(GetMenuState(menu,1,MF_BYCOMMAND)&MF_CHECKED),
|
||||||
|
"visible menu mnemonic is idempotent and preserves item state");
|
||||||
|
GetMenuStringW(menu,2,label,128,MF_BYCOMMAND);
|
||||||
|
require(std::wstring(label)==L"Copy\tCtrl+C","existing menu shortcut hints are retained");
|
||||||
|
GetMenuStringW(child,3,label,128,MF_BYCOMMAND);
|
||||||
|
require(std::wstring(label)==L"Rock && &Roll\tR","submenu hints skip escaped ampersands");
|
||||||
|
DestroyMenu(menu);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||||
ks::InputRegressionAccess::setup(app);
|
ks::InputRegressionAccess::setup(app);
|
||||||
@@ -629,6 +694,49 @@ int main(int argc, char **argv) {
|
|||||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||||
ks::showSettings(app,false);
|
ks::showSettings(app,false);
|
||||||
|
{
|
||||||
|
auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose);
|
||||||
|
require(timer != 0, "settings close prompt responder");
|
||||||
|
SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0);
|
||||||
|
require(!app.settingsWindow && settingsClosePrompts == 0,
|
||||||
|
"unchanged settings close without prompting");
|
||||||
|
ks::showSettings(app,false);
|
||||||
|
auto settingsWindow = app.settingsWindow;
|
||||||
|
auto initial = SendDlgItemMessageW(settingsWindow,205,BM_GETCHECK,0,0);
|
||||||
|
SendDlgItemMessageW(settingsWindow,205,BM_SETCHECK,!initial,0);
|
||||||
|
SendMessageW(settingsWindow, WM_CLOSE, 0, 0);
|
||||||
|
require(IsWindow(settingsWindow) && settingsClosePrompts == 1,
|
||||||
|
"cancel close preserves changed settings");
|
||||||
|
SendDlgItemMessageW(settingsWindow,205,BM_SETCHECK,initial,0);
|
||||||
|
SendMessageW(settingsWindow, WM_CLOSE, 0, 0);
|
||||||
|
require(!app.settingsWindow && settingsClosePrompts == 1,
|
||||||
|
"reverted settings close without prompting");
|
||||||
|
ks::showSettings(app,false);
|
||||||
|
SetDlgItemTextW(app.settingsWindow,240,L"invalid");
|
||||||
|
settingsCloseChoice = IDNO;
|
||||||
|
SendMessageW(app.settingsWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(!app.settingsWindow && settingsClosePrompts == 2,
|
||||||
|
"Cancel button allows discarding invalid edits");
|
||||||
|
auto originalSettings = app.settings;
|
||||||
|
// The isolated harness has no keyboard hook; save without shortcuts.
|
||||||
|
app.settings.toggle = app.settings.casing = app.settings.translit =
|
||||||
|
app.settings.undo = app.settings.clipboard = app.settings.screenshot = L"";
|
||||||
|
app.settings.direct.assign(app.layouts.size(), L"");
|
||||||
|
ks::showSettings(app,false);
|
||||||
|
SendDlgItemMessageW(app.settingsWindow,208,BM_SETCHECK,
|
||||||
|
originalSettings.writeLog ? BST_UNCHECKED : BST_CHECKED,0);
|
||||||
|
settingsCloseChoice = IDYES;
|
||||||
|
SendMessageW(app.settingsWindow,WM_CLOSE,0,0);
|
||||||
|
require(!app.settingsWindow && settingsClosePrompts == 3 &&
|
||||||
|
app.settings.writeLog != originalSettings.writeLog &&
|
||||||
|
ks::loadSettings(app.data / L"settings.json",app.layouts).writeLog == app.settings.writeLog,
|
||||||
|
"Yes saves settings to disk and applies them before closing");
|
||||||
|
app.settings = originalSettings;
|
||||||
|
ks::setLoggingEnabled(originalSettings.writeLog);
|
||||||
|
KillTimer(nullptr,timer);
|
||||||
|
settingsCloseChoice = IDCANCEL;
|
||||||
|
ks::showSettings(app,false);
|
||||||
|
}
|
||||||
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
|
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
|
||||||
// Mnemonics only target visible controls; show without taking foreground focus.
|
// Mnemonics only target visible controls; show without taking foreground focus.
|
||||||
ShowWindow(app.settingsWindow,SW_SHOWNOACTIVATE);
|
ShowWindow(app.settingsWindow,SW_SHOWNOACTIVATE);
|
||||||
@@ -716,6 +824,10 @@ int main(int argc, char **argv) {
|
|||||||
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||||
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||||
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
||||||
|
require(ks::InputRegressionAccess::idleUndo(app),"idle cleanup retains available/busy Undo and frees expired Undo");
|
||||||
|
for(int mode=0;mode<3;++mode)
|
||||||
|
require(ks::InputRegressionAccess::completedSync(app,mode),
|
||||||
|
"completed, failed and invalid sync release buffers but preserve conflict choices");
|
||||||
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
@@ -774,6 +886,14 @@ int main(int argc, char **argv) {
|
|||||||
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||||
|
{
|
||||||
|
auto timer = SetTimer(nullptr,0,10,answerSettingsClose);
|
||||||
|
auto before = settingsClosePrompts;
|
||||||
|
SendMessageW(app.settingsWindow,WM_CLOSE,0,0);
|
||||||
|
KillTimer(nullptr,timer);
|
||||||
|
require(app.settingsWindow && settingsClosePrompts == before + 1,
|
||||||
|
"accepted personal dictionary edits trigger unsaved settings warning");
|
||||||
|
}
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
ShowWindow(autoOptions,SW_HIDE);
|
ShowWindow(autoOptions,SW_HIDE);
|
||||||
@@ -819,6 +939,25 @@ int main(int argc, char **argv) {
|
|||||||
SetDlgItemTextW(history,101,L"");
|
SetDlgItemTextW(history,101,L"");
|
||||||
require(pumpUntil(2),"clearing search restores history");
|
require(pumpUntil(2),"clearing search restores history");
|
||||||
require(GetFocus()==originalFocus,"history search preserves focus");
|
require(GetFocus()==originalFocus,"history search preserves focus");
|
||||||
|
SendDlgItemMessageW(history,102,LB_SETCURSEL,1,0);
|
||||||
|
ks::refreshHistory(app); // A result may still be in flight when closing.
|
||||||
|
SendMessageW(history,WM_CLOSE,0,0);
|
||||||
|
SendMessageW(history,WM_TIMER,1,0); // KillTimer cannot retract a posted timer message.
|
||||||
|
require(pumpUntil(0),"closing history releases list strings");
|
||||||
|
auto until=GetTickCount64()+250;
|
||||||
|
while(GetTickCount64()<until) {
|
||||||
|
MSG message{};
|
||||||
|
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
||||||
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
Sleep(5);
|
||||||
|
}
|
||||||
|
require(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==0,
|
||||||
|
"late search results do not refill closed history");
|
||||||
|
ks::refreshHistory(app);
|
||||||
|
require(pumpUntil(2),"history reloads after releasing hidden rows");
|
||||||
|
require(SendDlgItemMessageW(history,102,LB_GETCURSEL,0,0)==1,
|
||||||
|
"history retains selected record across close and reload");
|
||||||
}
|
}
|
||||||
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
||||||
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
||||||
|
|||||||
+125
-2
@@ -9,6 +9,7 @@ namespace ks
|
|||||||
{
|
{
|
||||||
struct InputRegressionAccess
|
struct InputRegressionAccess
|
||||||
{
|
{
|
||||||
|
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||||
static bool deferring(App &app)
|
static bool deferring(App &app)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(app.stateMutex);
|
std::lock_guard lock(app.stateMutex);
|
||||||
@@ -28,6 +29,8 @@ int pasteBehavior = 0;
|
|||||||
int enterPresses = 0;
|
int enterPresses = 0;
|
||||||
int copyFailuresRemaining = 0, copyRequests = 0;
|
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||||
int droppedSelectionKeys = 0;
|
int droppedSelectionKeys = 0;
|
||||||
|
bool trimCopiedSpaces = false;
|
||||||
|
int trimmedCopies = 0;
|
||||||
bool temporaryClipboardExcluded = true;
|
bool temporaryClipboardExcluded = true;
|
||||||
bool clipboardAccessTimedOut = false;
|
bool clipboardAccessTimedOut = false;
|
||||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
@@ -76,10 +79,42 @@ LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_P
|
|||||||
--copyFailuresRemaining;
|
--copyFailuresRemaining;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (trimCopiedSpaces)
|
||||||
|
{
|
||||||
|
auto result = DefSubclassProc(w, message, wp, lp);
|
||||||
|
// Native EDIT closes the clipboard in its copy handler, including
|
||||||
|
// our outer opening. Reopen it before changing its published data.
|
||||||
|
if (!OpenClipboard(w))
|
||||||
|
{
|
||||||
|
clipboardAccessTimedOut = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
auto handle = GetClipboardData(CF_UNICODETEXT);
|
||||||
|
auto data = handle ? static_cast<const wchar_t *>(GlobalLock(handle)) : nullptr;
|
||||||
|
if (data)
|
||||||
|
{
|
||||||
|
std::wstring copied(data);
|
||||||
|
GlobalUnlock(handle);
|
||||||
|
const auto originalSize = copied.size();
|
||||||
|
while (!copied.empty() && copied.back() == L' ') copied.pop_back();
|
||||||
|
auto replacement = GlobalAlloc(GMEM_MOVEABLE, (copied.size() + 1) * sizeof(wchar_t));
|
||||||
|
auto output = GlobalLock(replacement);
|
||||||
|
memcpy(output, copied.c_str(), (copied.size() + 1) * sizeof(wchar_t));
|
||||||
|
GlobalUnlock(replacement);
|
||||||
|
if (!EmptyClipboard() || !SetClipboardData(CF_UNICODETEXT, replacement))
|
||||||
|
{
|
||||||
|
GlobalFree(replacement);
|
||||||
|
clipboardAccessTimedOut = true;
|
||||||
|
}
|
||||||
|
else if (copied.size() != originalSize) ++trimmedCopies;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (message == WM_PASTE && pasteBehavior)
|
if (message == WM_PASTE && pasteBehavior)
|
||||||
{
|
{
|
||||||
|
if (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
|
||||||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -371,6 +406,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||||||
"blocked paste records original text remaining in the actual editor");
|
"blocked paste records original text remaining in the actual editor");
|
||||||
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
|
||||||
pasteBehavior = 2;
|
pasteBehavior = 2;
|
||||||
prepare(L"ghbdtn", true);
|
prepare(L"ghbdtn", true);
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -381,6 +417,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||||
"unexpected editor content records readback mismatch");
|
"unexpected editor content records readback mismatch");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
|
||||||
pasteBehavior = 0;
|
pasteBehavior = 0;
|
||||||
prepare(L"привет", true);
|
prepare(L"привет", true);
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -449,9 +486,95 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
details.find(L"Selection initial match=0") != details.npos &&
|
details.find(L"Selection initial match=0") != details.npos &&
|
||||||
details.find(L"SelectPrevious") != details.npos,
|
details.find(L"SelectPrevious") != details.npos,
|
||||||
"incident includes clipboard and selection stages");
|
"incident includes clipboard and selection stages");
|
||||||
finish();
|
pasteBehavior = 3;
|
||||||
|
prepare(L"=nhtdjuf", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
stage = 49;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 49:
|
||||||
|
{
|
||||||
|
require(text() == L"=тревога" && pasteBehavior == 0,
|
||||||
|
"native Edit retries one ignored paste only while original remains selected");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard");
|
||||||
|
trimCopiedSpaces = true;
|
||||||
|
app->settings.writeLog = false;
|
||||||
|
prepare(L"OLDghbdtn NEXT", false);
|
||||||
|
SendMessageW(field, EM_SETSEL, 11, 11);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 50:
|
||||||
|
require(text() == L"OLDпривет NEXT", "trimmed clipboard recovery preserves two spaces and surrounding text without logging");
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 51:
|
||||||
|
require(text() == L"OLDghbdtn NEXT", "repeat conversion preserves accessibility-proven spaces");
|
||||||
|
app->transform(ks::Undo);
|
||||||
|
break;
|
||||||
|
case 52:
|
||||||
|
require(text() == L"OLDпривет NEXT", "undo preserves accessibility-proven spaces");
|
||||||
|
prepare(L"ghbdtn", false);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 53:
|
||||||
|
require(text() == L"ghbdtn", "missing real space cannot be inferred from tracked keys");
|
||||||
|
prepare(L"wrong ", false);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 54:
|
||||||
|
require(text() == L"wrong ", "unrelated selection remains rejected with trimmed clipboard");
|
||||||
|
app->settings.autoCorrect = true;
|
||||||
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
// The worker now shares the predictor's immutable dictionaries.
|
||||||
|
// This fixture changes settings directly instead of applySettings.
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"ghbdtn ", false);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 55:
|
||||||
|
require(text() == L"привет ", "automatic correction recovers copied trailing space without logging");
|
||||||
|
app->transform(ks::Undo);
|
||||||
|
break;
|
||||||
|
case 56:
|
||||||
|
{
|
||||||
|
require(text() == L"ghbdtn ", "automatic correction undo retains recovered space");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "space recovery restores clipboard");
|
||||||
|
pasteBehavior = 1;
|
||||||
|
prepare(L"ghbdtn ", false);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 57:
|
||||||
|
{
|
||||||
|
require(text() == L"ghbdtn " && !ks::InputRegressionAccess::hasLast(*app),
|
||||||
|
"recovered selection with blocked paste clears repeat state even without logging");
|
||||||
|
require(trimmedCopies >= 7, "fixture actually stripped spaces during recovery scenarios");
|
||||||
|
pasteBehavior = 0;
|
||||||
|
trimCopiedSpaces = false;
|
||||||
|
// User-selected middle range, followed by the same command with
|
||||||
|
// no selection: retain both surrounding text and trailing spaces.
|
||||||
|
prepare(L"OLDghbdtn NEXT", false);
|
||||||
|
SendMessageW(field, EM_SETSEL, 3, 11);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 58:
|
||||||
|
require(text() == L"OLDпривет NEXT", "explicit middle selection preserves surrounding text");
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 59:
|
||||||
|
require(text() == L"OLDghbdtn NEXT", "hotkey without selection reconverts only the previous middle range");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
case 32:
|
case 32:
|
||||||
case 34:
|
case 34:
|
||||||
@@ -853,7 +976,7 @@ int main(int argc, char **argv)
|
|||||||
field =
|
field =
|
||||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
deadline = GetTickCount64() + 60000;
|
||||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||||
stage = 20;
|
stage = 20;
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
static std::wstring programs;
|
static std::wstring programs;
|
||||||
static std::wstring activationExecutable, activationCommand;
|
static std::wstring activationExecutable, activationCommand;
|
||||||
static int activationCalls{};
|
static int activationCalls{};
|
||||||
|
static DWORD launchFlags{}; static bool inheritsHandles{};
|
||||||
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
||||||
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
LPSECURITY_ATTRIBUTES, BOOL inherit, DWORD flags, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
||||||
{
|
{
|
||||||
|
launchFlags=flags; inheritsHandles=inherit;
|
||||||
++activationCalls; activationExecutable=path; activationCommand=command;
|
++activationCalls; activationExecutable=path; activationCommand=command;
|
||||||
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,14 @@ int main()
|
|||||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
||||||
|
bool launchFailed=false;
|
||||||
|
try { ks::startScreenshotNotificationRegistration(); }
|
||||||
|
catch(const std::exception &) { launchFailed=true; }
|
||||||
|
if(!launchFailed || activationExecutable!=helper.wstring() ||
|
||||||
|
activationCommand!=L"\""+helper.wstring()+L"\" --register-notifications" ||
|
||||||
|
!(launchFlags&CREATE_NO_WINDOW) || inheritsHandles)
|
||||||
|
throw std::runtime_error("registration must launch the sibling helper and propagate failure");
|
||||||
|
activationCalls=0;
|
||||||
ks::Activator activator(helper.wstring(),false);
|
ks::Activator activator(helper.wstring(),false);
|
||||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
||||||
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
||||||
|
|||||||
+20
-1
@@ -225,6 +225,17 @@ int main(int argc, char **argv)
|
|||||||
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
|
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
|
||||||
"Ctrl+wheel zoom keeps image point under cursor");
|
"Ctrl+wheel zoom keeps image point under cursor");
|
||||||
require(!c.history.canUndo(),"zoom does not add image edits to undo history");
|
require(!c.history.canUndo(),"zoom does not add image edits to undo history");
|
||||||
|
// A fixed zoom can still fit inside a high-DPI editor. Size the
|
||||||
|
// fixture from the actual viewport, with room for the tested drag
|
||||||
|
// on both axes, then start from a centered view.
|
||||||
|
const double panZoom=std::max({1.0,
|
||||||
|
double(c.canvas.right-c.canvas.left+128)/(c.view.right-c.view.left),
|
||||||
|
double(c.canvas.bottom-c.canvas.top+128)/(c.view.bottom-c.view.top)});
|
||||||
|
ks::zoomCanvas(window,c,panZoom,center);
|
||||||
|
c.panX=c.panY=0; ks::layout(window,c);
|
||||||
|
require(c.view.left+15<c.canvas.left && c.view.top+10<c.canvas.top &&
|
||||||
|
c.view.right>c.canvas.right && c.view.bottom>c.canvas.bottom,
|
||||||
|
"pan fixture has drag clearance on both axes within the supported zoom range");
|
||||||
auto view=c.view;
|
auto view=c.view;
|
||||||
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
|
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
|
||||||
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));
|
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));
|
||||||
@@ -443,8 +454,16 @@ int main(int argc, char **argv)
|
|||||||
c.marks={{{20,20},{120,20},0}};
|
c.marks={{{20,20},{120,20},0}};
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
|
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
|
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEWE),"endpoint resize overrides drawing tool");
|
for(auto part:{ks::MarkPart::Start,ks::MarkPart::End,ks::MarkPart::Radius,
|
||||||
|
ks::MarkPart::Label,ks::MarkPart::Vertex0,ks::MarkPart::Vertex1,ks::MarkPart::Vertex2})
|
||||||
|
require(ks::actionCursor(c,{0,part},true)==LoadCursorW(nullptr,IDC_SIZEALL),
|
||||||
|
"freely movable handles use four-direction cursor for every drawing tool");
|
||||||
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
|
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
|
||||||
|
c.marks[0].to={20,120};
|
||||||
|
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEALL),
|
||||||
|
"vertical line endpoint still moves in both axes");
|
||||||
|
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZEWE),
|
||||||
|
"vertical line thickness retains horizontal resize cursor");
|
||||||
}
|
}
|
||||||
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
||||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||||
|
|||||||
+66
-14
@@ -23,20 +23,6 @@ void rejects(const std::function<void()> &f, const char *name)
|
|||||||
bool thrown = false;
|
bool thrown = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
{
|
|
||||||
std::map<std::wstring,int> previews;
|
|
||||||
std::set<std::wstring> visible;
|
|
||||||
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
|
||||||
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
|
||||||
ks::trimHistoryPreviews(previews,visible);
|
|
||||||
require(previews.size()==32,"preview cache stays bounded");
|
|
||||||
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
|
||||||
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
|
||||||
ks::trimHistoryPreviews(previews,visible);
|
|
||||||
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
|
||||||
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
|
||||||
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
|
||||||
}
|
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
@@ -60,6 +46,34 @@ int main(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Byte budgets must retain visible entries even when they alone exceed the budget.
|
||||||
|
{
|
||||||
|
std::map<std::wstring, size_t> cache{{L"a", 800}, {L"b", 800}, {L"c", 100}};
|
||||||
|
std::set<std::wstring> visible{L"b"};
|
||||||
|
auto weight=[](size_t bytes) { return bytes; };
|
||||||
|
ks::trimHistoryPreviews(cache, visible, 32, 1000, weight);
|
||||||
|
require(cache.size()==2 && cache.contains(L"b") && cache.contains(L"c"),
|
||||||
|
"byte budget evicts offscreen previews below count limit");
|
||||||
|
ks::trimHistoryPreviews(cache, visible, 32, 100, weight);
|
||||||
|
require(cache.size()==1 && cache.contains(L"b"), "oversized visible preview never thrashes");
|
||||||
|
visible.clear();
|
||||||
|
ks::trimHistoryPreviews(cache, visible, 32, 100, weight);
|
||||||
|
require(cache.empty(), "oversized preview is freed after leaving viewport");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
}
|
||||||
{
|
{
|
||||||
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
||||||
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
||||||
@@ -99,6 +113,10 @@ int main(int argc, char **argv)
|
|||||||
"large diagnostic selections are explicitly truncated");
|
"large diagnostic selections are explicitly truncated");
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
ks::DiagnosticBuffer diagnostics;
|
ks::DiagnosticBuffer diagnostics;
|
||||||
|
diagnostics.record(L"idle-expiry", false, 0);
|
||||||
|
require(diagnostics.expire(30000)==0,"diagnostics remain available at expiry boundary");
|
||||||
|
require(diagnostics.expire(30001)==1,"idle expiry releases context without another log event");
|
||||||
|
require(diagnostics.expire(60000)==0,"repeated idle expiry is safe");
|
||||||
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
|
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
|
||||||
"successful diagnostics stay in memory");
|
"successful diagnostics stay in memory");
|
||||||
auto incident = diagnostics.record(L"failed-attempt", true, 200);
|
auto incident = diagnostics.record(L"failed-attempt", true, 200);
|
||||||
@@ -217,6 +235,26 @@ int main(int argc, char **argv)
|
|||||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||||
autoSettings.cycle = {L"en", L"uk"};
|
autoSettings.cycle = {L"en", L"uk"};
|
||||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
||||||
|
{
|
||||||
|
ks::AutoCorrector consumer;
|
||||||
|
consumer.shareDictionaries(corrector);
|
||||||
|
require(consumer.suggest(L"ghbdsn ", 0, layouts, autoSettings).has_value(),
|
||||||
|
"shared dictionaries produce the same correction");
|
||||||
|
consumer.reject(L"ghbdsn");
|
||||||
|
consumer.shareDictionaries(corrector);
|
||||||
|
require(!consumer.suggest(L"ghbdsn ", 0, layouts, autoSettings),
|
||||||
|
"refreshing shared dictionaries preserves consumer undo rejections");
|
||||||
|
require(corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings).has_value(),
|
||||||
|
"consumer rejections do not affect the predictor");
|
||||||
|
ks::AutoCorrector retained;
|
||||||
|
retained.shareDictionaries(corrector);
|
||||||
|
corrector.configure({}, {});
|
||||||
|
require(retained.suggest(L"ghbdsn ", 0, layouts, autoSettings).has_value(),
|
||||||
|
"in-flight dictionary snapshot survives source reset");
|
||||||
|
consumer.shareDictionaries(corrector);
|
||||||
|
require(consumer.memoryBytes() == 0, "disabled correction releases consumer dictionaries");
|
||||||
|
corrector.shareDictionaries(retained);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
auto options = autoSettings;
|
auto options = autoSettings;
|
||||||
options.autoTypos = true;
|
options.autoTypos = true;
|
||||||
@@ -545,6 +583,20 @@ int main(int argc, char **argv)
|
|||||||
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
||||||
}
|
}
|
||||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||||
|
{
|
||||||
|
auto png = ks::encodePng(pixels);
|
||||||
|
ks::Clip encoded{{{0, L"PNG", png}}};
|
||||||
|
for (int i=0; i<3; ++i) {
|
||||||
|
auto decoded=ks::decodeImage(encoded);
|
||||||
|
require(decoded.bgra==pixels.bgra && decoded.width==2 && decoded.height==1,
|
||||||
|
"borrowed PNG stream preserves decoded pixels on repeated reads");
|
||||||
|
require(encoded.formats[0].data==png,"PNG decoder never modifies the borrowed input");
|
||||||
|
}
|
||||||
|
ks::Clip fallback{{{0,L"PNG",{}},ks::imageDib(pixels)}};
|
||||||
|
require(ks::decodeImage(fallback).bgra==pixels.bgra,"empty PNG falls back to DIB");
|
||||||
|
ks::Clip invalid{{{0,L"PNG",{1,2,3}}}};
|
||||||
|
rejects([&] { ks::decodeImage(invalid); },"invalid borrowed PNG fails safely");
|
||||||
|
}
|
||||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||||
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
||||||
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
|
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
param([ValidateRange(1,20)][int]$Runs=3,[ValidateRange(100,10000)][int]$SettleMilliseconds=1500)
|
||||||
|
# Private resident pages are counted externally, so the measurement buffer is
|
||||||
|
# allocated in PowerShell, not in the measured process. Never trim working sets.
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
$root=Split-Path $PSScriptRoot -Parent
|
||||||
|
$output=Join-Path $root 'build/memory-components'
|
||||||
|
New-Item -ItemType Directory -Force $output | Out-Null
|
||||||
|
$exe=Join-Path $output 'probe.exe'
|
||||||
|
& "$root/.tools/w64devkit/bin/g++.exe" -std=c++20 -Os -static -s -DUNICODE -D_UNICODE -D_WIN32_WINNT=0x0A00 "$PSScriptRoot/memory-components.cpp" -o $exe -lole32 -loleaut32 -lshell32 -lpropsys -lshlwapi -luuid -luiautomationcore
|
||||||
|
if($LASTEXITCODE) { throw 'Probe compilation failed' }
|
||||||
|
Add-Type @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class ComponentWorkingSet {
|
||||||
|
[DllImport("psapi.dll", SetLastError=true)] static extern bool QueryWorkingSet(IntPtr process,IntPtr buffer,int size);
|
||||||
|
public static long PrivateBytes(IntPtr process) {
|
||||||
|
int size=4*1024*1024;
|
||||||
|
IntPtr buffer=Marshal.AllocHGlobal(size);
|
||||||
|
try {
|
||||||
|
if(!QueryWorkingSet(process,buffer,size)) throw new System.ComponentModel.Win32Exception();
|
||||||
|
long count=Marshal.ReadIntPtr(buffer).ToInt64(), pages=0;
|
||||||
|
if(count>(size/IntPtr.Size)-1) throw new InvalidOperationException("Working set buffer is too small");
|
||||||
|
for(int i=0;i<count;i++) if((Marshal.ReadIntPtr(buffer,(i+1)*IntPtr.Size).ToInt64() & 256)==0) pages++;
|
||||||
|
return pages*Environment.SystemPageSize;
|
||||||
|
} finally { Marshal.FreeHGlobal(buffer); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
$rows=[Collections.Generic.List[object]]::new()
|
||||||
|
foreach($run in 1..$Runs) {
|
||||||
|
foreach($mode in @('baseline','sta','mta','shell','uia','focused','combined')) {
|
||||||
|
$start=[Diagnostics.ProcessStartInfo]::new($exe,$mode)
|
||||||
|
$start.UseShellExecute=$false; $start.CreateNoWindow=$true
|
||||||
|
$start.RedirectStandardOutput=$true; $start.RedirectStandardInput=$true; $start.RedirectStandardError=$true
|
||||||
|
$process=[Diagnostics.Process]::Start($start)
|
||||||
|
$errors=$process.StandardError.ReadToEndAsync()
|
||||||
|
try {
|
||||||
|
foreach($expected in @('baseline','com','active','released','uninitialized')) {
|
||||||
|
$pending=$process.StandardOutput.ReadLineAsync()
|
||||||
|
if(!$pending.Wait(15000)) { throw "Timeout: $mode/$expected" }
|
||||||
|
$phase=$pending.Result
|
||||||
|
if($phase -ne $expected) { throw "Unexpected phase '$phase', expected $expected" }
|
||||||
|
Start-Sleep -Milliseconds $SettleMilliseconds
|
||||||
|
$process.Refresh()
|
||||||
|
$rows.Add([pscustomobject]@{Run=$run;Mode=$mode;Phase=$phase;PrivateBytes=$process.PrivateMemorySize64;PrivateWorkingSet=[ComponentWorkingSet]::PrivateBytes($process.Handle);WorkingSet=$process.WorkingSet64;Threads=$process.Threads.Count;Modules=@($process.Modules | ForEach-Object ModuleName)})
|
||||||
|
$process.StandardInput.Write("`n"); $process.StandardInput.Flush()
|
||||||
|
}
|
||||||
|
if(!$process.WaitForExit(15000)) { throw 'Exit timeout' }
|
||||||
|
if($process.ExitCode) { throw "Probe failed: $($errors.Result)" }
|
||||||
|
$errors.Result | Set-Content (Join-Path $output "$run-$mode.txt")
|
||||||
|
} finally {
|
||||||
|
if(!$process.HasExited) { $process.Kill(); $process.WaitForExit() }
|
||||||
|
$process.Dispose()
|
||||||
|
$rows | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $output 'results.json')
|
||||||
|
}
|
||||||
|
Write-Host "Completed $run/$Runs $mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows | Group-Object Mode,Phase | ForEach-Object {
|
||||||
|
[pscustomobject]@{Case=$_.Name;PrivateMiB=[math]::Round(($_.Group.PrivateBytes | Measure-Object -Average).Average/1MB,3);PrivateWorkingSetMiB=[math]::Round(($_.Group.PrivateWorkingSet | Measure-Object -Average).Average/1MB,3)}
|
||||||
|
} | Format-Table
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Standalone attribution probe. No clipboard, registry or user input mutations.
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <propkey.h>
|
||||||
|
#include <propvarutil.h>
|
||||||
|
#include <uiautomation.h>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
static void check(HRESULT hr) {
|
||||||
|
if (FAILED(hr)) { fprintf(stderr,"HRESULT=%08lx\n",(unsigned long)hr); exit(2); }
|
||||||
|
}
|
||||||
|
static void phase(const char *name) {
|
||||||
|
printf("%s\n",name); fflush(stdout);
|
||||||
|
if (getchar()==EOF) exit(3);
|
||||||
|
}
|
||||||
|
int main(int argc,char **argv) {
|
||||||
|
const char *mode=argc>1?argv[1]:"baseline";
|
||||||
|
const bool baseline=!strcmp(mode,"baseline");
|
||||||
|
const bool shell=!strcmp(mode,"shell") || !strcmp(mode,"combined");
|
||||||
|
const bool uia=!strcmp(mode,"uia") || !strcmp(mode,"focused") || !strcmp(mode,"combined");
|
||||||
|
phase("baseline");
|
||||||
|
if(!baseline) check(CoInitializeEx(nullptr,!strcmp(mode,"sta") || shell ? COINIT_APARTMENTTHREADED : COINIT_MULTITHREADED));
|
||||||
|
phase("com");
|
||||||
|
IShellLinkW *link{}; IPropertyStore *store{}; IPersistFile *file{};
|
||||||
|
IUIAutomation2 *automation{}; IUIAutomationElement *element{}; IUIAutomationTextPattern *pattern{};
|
||||||
|
wchar_t temporary[MAX_PATH]{},shortcut[MAX_PATH]{},exe[MAX_PATH]{};
|
||||||
|
if(shell) {
|
||||||
|
check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
||||||
|
GetModuleFileNameW(nullptr,exe,MAX_PATH);
|
||||||
|
check(link->SetPath(exe)); check(link->SetIconLocation(exe,0));
|
||||||
|
check(link->QueryInterface(IID_PPV_ARGS(&store)));
|
||||||
|
PROPVARIANT value{}; check(InitPropVariantFromString(L"KSwitcher.MemoryProbe",&value));
|
||||||
|
check(store->SetValue(PKEY_AppUserModel_ID,value)); PropVariantClear(&value);
|
||||||
|
GUID id{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||||
|
value.vt=VT_CLSID; value.puuid=&id;
|
||||||
|
check(store->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,value)); check(store->Commit());
|
||||||
|
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs)); CoTaskMemFree(programs);
|
||||||
|
GetTempPathW(MAX_PATH,temporary); if(!GetTempFileNameW(temporary,L"ksm",0,shortcut)) return 4;
|
||||||
|
check(link->QueryInterface(IID_PPV_ARGS(&file))); check(file->Save(shortcut,TRUE));
|
||||||
|
}
|
||||||
|
if(uia) {
|
||||||
|
check(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&automation)));
|
||||||
|
check(automation->put_ConnectionTimeout(200)); check(automation->put_TransactionTimeout(200));
|
||||||
|
if(strcmp(mode,"uia")) {
|
||||||
|
HRESULT focused=automation->GetFocusedElement(&element);
|
||||||
|
fprintf(stderr,"focus=%08lx\n",(unsigned long)focused);
|
||||||
|
if(SUCCEEDED(focused) && element) {
|
||||||
|
BOOL password=TRUE,enabled=FALSE; CONTROLTYPEID type{};
|
||||||
|
element->get_CurrentIsPassword(&password); element->get_CurrentIsEnabled(&enabled); element->get_CurrentControlType(&type);
|
||||||
|
fprintf(stderr,"type=%d enabled=%d protected=%d\n",type,enabled,password);
|
||||||
|
if(!password) fprintf(stderr,"pattern=%08lx\n",(unsigned long)element->GetCurrentPatternAs(UIA_TextPatternId,IID_PPV_ARGS(&pattern)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase("active");
|
||||||
|
if(pattern) pattern->Release(); if(element) element->Release(); if(automation) automation->Release();
|
||||||
|
if(file) file->Release(); if(store) store->Release(); if(link) link->Release();
|
||||||
|
if(shortcut[0]) DeleteFileW(shortcut);
|
||||||
|
phase("released");
|
||||||
|
if(!baseline) { CoFreeUnusedLibrariesEx(0,0); CoUninitialize(); }
|
||||||
|
phase("uninitialized");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user