Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 004fee4976 | |||
| b08c5ff8ff | |||
| b6e7bda220 | |||
| 89e506faae | |||
| c78b51d341 | |||
| 0184b837f4 |
+1
-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.34 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -16,10 +16,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.
|
||||||
@@ -685,3 +698,7 @@ 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.
|
||||||
|
|||||||
Binary file not shown.
+9
-19
@@ -1,29 +1,19 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.29 — 2026-11-02
|
## v0.6.34 — 2026-11-04
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item.
|
- Screenshot editing now uses the four-direction move cursor for both endpoints of lines/arrows, bounding-box shape handles and number-radius handles. The cursor no longer suggests that a horizontal or vertical line endpoint is restricted to one axis.
|
||||||
- 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.
|
- Object bodies, arrow labels and triangle vertices continue to use the same four-direction cursor. Thickness handles retain directional resize cursors perpendicular to the line; rotation handles retain the hand cursor.
|
||||||
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action.
|
- This changes cursor feedback only. Dragging, snapping, circle proportions, undo/redo, shortcuts, defaults and dialog access-key behavior are unchanged. The v0.6.33 unsaved-settings warning and visible menu access letters remain included.
|
||||||
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management.
|
|
||||||
|
|
||||||
## 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.
|
The full `build.ps1 -Stage -Test` run passed: 105 sync checks, shared wheel batching, 483 native regressions, 551 input/UI checks at 96 DPI, screenshot editor UI checks (including free-movement cursor feedback across drawing tools and horizontal/vertical thickness cursors), PNG saving, isolated screenshot registration, 48 updater regressions and updater progress UI checks. All three executable versions and all 12 package manifest file sizes/SHA-256 hashes were verified; the package contains no personal data.
|
||||||
|
|
||||||
## Known limitations
|
## Upgrade
|
||||||
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.
|
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:
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 KSwitcher-win-x64.zip
|
c7f004ecd9d0f1032356060191ca3ca9fca458df434d3e5908c5d9b038f95acc KSwitcher-win-x64.zip
|
||||||
|
|||||||
+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,34,0
|
||||||
PRODUCTVERSION 0,6,29,0
|
PRODUCTVERSION 0,6,34,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.34"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+111
-3
@@ -76,6 +76,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 +252,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,6 +345,15 @@ 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);
|
||||||
@@ -338,6 +368,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)
|
||||||
@@ -1263,6 +1319,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 +1371,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 +1404,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 +1654,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);
|
||||||
|
|||||||
+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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+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];
|
||||||
|
|||||||
+43
-2
@@ -867,6 +867,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)
|
||||||
@@ -1338,6 +1339,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 +1444,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 +1495,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.34";
|
||||||
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -80,6 +93,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 +661,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);
|
||||||
@@ -774,6 +849,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);
|
||||||
|
|||||||
+108
-1
@@ -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,6 +486,76 @@ 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");
|
||||||
|
pasteBehavior = 3;
|
||||||
|
prepare(L"=nhtdjuf", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
stage = 49;
|
||||||
|
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"};
|
||||||
|
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;
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -853,7 +960,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;
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
Reference in New Issue
Block a user