Compare commits

..

5 Commits

15 changed files with 435 additions and 36 deletions
+1 -1
View File
@@ -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.33 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()
+18 -4
View File
@@ -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.
@@ -271,7 +277,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 +695,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.
+10 -18
View File
@@ -1,29 +1,21 @@
# Latest Windows release # Latest Windows release
## v0.6.29 — 2026-11-02 ## v0.6.33 — 2026-11-04
## Changes ## Changes
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item. - Settings now detects unsaved changes when closing with the window close button, Alt+F4 or Cancel, including accepted auto-correction dictionary and OCR option edits.
- 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. - Choose Yes to save, apply and close; No to discard; or Cancel to keep editing (the default). Unchanged or reverted settings close without a prompt. Invalid edits can be discarded, and a failed save keeps the window and draft open.
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action. - Tray and clipboard-history menus now show access letters in the right-hand column, including submenu items. Press the letter without Alt; Alt dismisses an open menu. Existing shortcut hints are preserved.
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management. - Dialog access-key behavior is unchanged: use Alt+letter, with underline visibility following Windows preferences. Windows-wide preferences and global shortcut assignments are unchanged.
- OK in Auto-correction/OCR options still updates the Settings draft; Save or Yes in the close prompt applies it. Snippets and Sync retain their separate save flows.
## 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, 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. New regressions cover unchanged/reverted settings, cancelling closure, discarding invalid edits, saving and applying edits, personal dictionary changes, and visible menu hints including submenus and existing shortcut labels.
## 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:
```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
View File
@@ -1 +1 @@
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 KSwitcher-win-x64.zip e46d1f6d52aeb58cf96c74db64bd450bc5b828e0a5111673c19a6657fca1d214 KSwitcher-win-x64.zip
+3 -3
View File
@@ -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,33,0
PRODUCTVERSION 0,6,29,0 PRODUCTVERSION 0,6,33,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.33"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+111 -3
View File
@@ -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
View File
@@ -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)
{ {
+25
View File
@@ -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))
+1
View File
@@ -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);
+43 -2
View File
@@ -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
View File
@@ -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.33";
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;
+83
View File
@@ -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
View File
@@ -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;
+11
View File
@@ -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));