Compare commits

...

7 Commits

16 changed files with 463 additions and 43 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.28 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.34 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+25 -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 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.
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,
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
the underlines when Windows hides them. Shortcut-capture fields still record Alt combinations;
to activate a button, toggle a checkbox or focus a labeled field. Dialog underline visibility
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.
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.
@@ -170,7 +175,8 @@ for shapes.
**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
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.
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,
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.
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.
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
@@ -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.
A silently dropped copy is retried up to three total attempts while the clipboard and input
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
history and cloud synchronization. Paste is not blindly retried.
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.
- 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.
@@ -321,6 +334,10 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
**Clear history… / Alt+H** in the history window clears all saved entries after confirmation,
including pinned entries and entries hidden by search. No is the default choice. The current
Windows clipboard and future recording are unchanged. Settings also retains its Clear history action.
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
in the context menu opens the original image in the built-in screenshot editor.
Annotate it there, then copy the result. Opening the editor leaves the
@@ -681,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.
Keep the compatibility bridge in the first 100 releases returned by Gitea while
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.
+8 -19
View File
@@ -1,30 +1,19 @@
# Latest Windows release
## v0.6.28 — 2026-11-02
## v0.6.34 — 2026-11-04
## Changes
- Custom automatic replacement pairs and optional dictionary typo correction. Both are off by default; enable Automatic correction and the desired modes in Auto-correction options. Custom pairs take priority; dictionary correction accepts one unambiguous single edit in words of at least four letters. Case, punctuation, exclusions and Undo are preserved.
- First synchronization now merges independent entries immediately. Different values for one setting remain pending as individual conflicts instead of requiring Download/Upload of a whole profile.
- Optional partial sync keeps this PC's layout cycle and direct shortcuts while syncing other settings. Existing server layouts and bindings are preserved, including during explicit Upload. Missing-layout errors identify the language where available and exact layout IDs.
- Disabled synchronization disables dependent controls. Save can persist disabling without connecting; forgetting the saved password remains available. Status messages can be selected, copied and scrolled.
- Selection diagnostics record expected/copied text before and after recovery, clipboard sequences and retries, timing, focus/caret information and context interruptions. Failed verification now includes expected and actual text. Context retains up to 128 events for 30 seconds.
- 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.
- 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.
- 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.
## Validation
105 sync/protocol/conflict checks and 483 native regression checks and 48 updater checks passed. All 12 manifest files and all three executable versions were verified in the release ZIP. Focused partial-sync UI checks and screenshot PNG-save regressions passed. During development, conversion/diagnostic and typo integration scenarios passed, including Undo and uninterrupted typing. Final interactive input testing is limited by this execution session reporting no foreground window; the full screenshot UI suite also failed its pan assertion at 240 DPI. These interactive checks are not claimed as passing.
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.
## Limitations
Dictionary typo correction is experimental and has no sentence context. Ambiguous words are unchanged. The reported asynchronous selection mismatch in ChatGPT is not fixed by the new diagnostics; full-range verification still cancels unsafe replacements. OpenBao synchronization remains experimental and requires HTTPS, KV v2 and a provisioned userpass account/policy. Ordered layout/language lists are resolved as whole settings; partial mode keeps the entire local layout configuration. Update participating PCs together for the new profile fields. No account registration, MFA, non-root namespaces or reverse-proxy URL prefixes.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
## Upgrade
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
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.
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together. The package contains no personal settings, credentials or clipboard history.
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 @@
c15937f52c981e7c6b15203aae51ae47dd2a261b495bd458533b01fd4cfe5498 KSwitcher-win-x64.zip
c7f004ecd9d0f1032356060191ca3ca9fca458df434d3e5908c5d9b038f95acc 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"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,28,0
PRODUCTVERSION 0,6,28,0
FILEVERSION 0,6,34,0
PRODUCTVERSION 0,6,34,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.28"
VALUE "FileVersion", "0.6.34"
END
END
BLOCK "VarFileInfo"
+111 -3
View File
@@ -76,6 +76,8 @@ class Transaction
mutable bool invalidLogged{};
unsigned copyNumber{};
std::wstring adopted;
std::unique_ptr<PasteProbe> selectionProof;
std::wstring provenSelection;
public:
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;
}
bool recoveredSelection() const { return selectionProof != nullptr; }
bool select(size_t n)
{
return selectPrevious(n, [&] { return valid(); });
@@ -324,6 +345,15 @@ class Transaction
}
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())
return false;
write(clip);
@@ -338,6 +368,32 @@ class Transaction
Sleep(300);
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
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;
verification = L"selection-mismatch";
reason = L"previous-result-verification-mismatch";
{
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
}
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -1310,13 +1371,30 @@ void App::transform(int command, int snippet)
}
}
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) +
L" changed=" + std::to_wstring(result != text));
if (result != text)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
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)
autoSelected = false;
reason = result == text ? L"conversion-unchanged" :
@@ -1326,19 +1404,48 @@ void App::transform(int command, int snippet)
reason = L"Conversion unchanged";
verification = L"calculated-result-equals-original";
}
if (sent && manual && config.writeLog)
if (sent && ((manual && config.writeLog) || requireReadback))
{
verification = probe.status;
if (tx.valid())
{
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;
if (observed && tx.valid())
{
actual = *observed;
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";
}
@@ -1547,6 +1654,7 @@ void App::trayMenu()
GetCursorPos(&pt);
pasteTarget = GetForegroundWindow();
SetForegroundWindow(window);
showMenuAccessKeys(menu);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, 0, window, nullptr);
DestroyMenu(menu);
PostMessageW(window, WM_NULL, 0, 0);
+18 -1
View File
@@ -13,7 +13,7 @@ class PasteProbe
public:
std::wstring status = L"not-requested";
PasteProbe(bool enabled, const std::wstring &original)
PasteProbe(bool enabled, HWND target)
{
if (!enabled)
return;
@@ -25,6 +25,18 @@ class PasteProbe
automation->put_TransactionTimeout(200);
if (FAILED(automation->GetFocusedElement(&element)) || !element)
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;
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
{
@@ -35,6 +47,11 @@ class PasteProbe
(void **)&pattern)) ||
!pattern)
return;
}
PasteProbe(bool enabled, const std::wstring &original, HWND target = nullptr)
: PasteProbe(enabled, target)
{
if (!pattern) return;
auto selected = read(1, false);
auto normalize = [](std::wstring value)
{
+25
View File
@@ -159,6 +159,31 @@ void showRequestedWindow(HWND window)
if (!IsWindowVisible(window))
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)
{
if (!IsWindow(window))
+1
View File
@@ -38,6 +38,7 @@ HKL windowLayout(HWND);
bool switchWindowLayout(HWND, HKL, const std::function<bool()> &valid);
bool activateWindow(HWND);
void showRequestedWindow(HWND);
void showMenuAccessKeys(HMENU);
bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
+5 -4
View File
@@ -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;
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::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Radius)
cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Thickness) {
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));
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
angle+=3.141592653589793/2;
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis];
+52 -3
View File
@@ -83,7 +83,7 @@ void arrangeWindow(HWND w)
{
int id = GetDlgCtrlID(child);
RECT next{scale(w, r.left) - layout->x,
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
scale(w, r.top) - layout->y + ((id >= 110 && id <= 114) || id == 116 ? extraHeight : 0)};
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
if (child == layout->stretch)
@@ -730,6 +730,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == 102 && HIWORD(wp) == LBN_DBLCLK)
id = 110;
if (id == 116)
{
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items? This also deletes items hidden by the current search.",
L"Clear history", MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2) == IDYES)
v->app->clearHistory();
return 0;
}
auto entry = selected(w, v);
if (id == 110 || id == 111)
{
@@ -860,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, 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)");
showMenuAccessKeys(menu);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
DestroyMenu(menu);
if (cmd)
@@ -1331,6 +1339,46 @@ void showOcrOptions(HWND parent, SettingsView *v)
showRequestedWindow(w); SetForegroundWindow(w);
}
#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)
{
if (msg == WM_DPICHANGED)
@@ -1396,7 +1444,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == IDCANCEL)
{
DestroyWindow(w);
closeSettings(w, v);
return 0;
}
if (id == 250)
@@ -1447,7 +1495,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
}
case WM_CLOSE:
DestroyWindow(w);
closeSettings(w, v);
return 0;
case WM_NCDESTROY:
disposeFont(w);
@@ -1650,6 +1698,7 @@ void showHistory(App &app, bool visible)
button(w, L"P&in / unpin", 112, 222, 452, 110);
button(w, L"&Delete", 113, 340, 452, 90);
button(w, L"&Open item", 114, 438, 452, 100);
button(w, L"Clear &history…", 116, 546, 452, 150);
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.28";
inline constexpr wchar_t AppVersion[] = L"0.6.34";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+83
View File
@@ -5,6 +5,19 @@
#include <commctrl.h>
#include <iostream>
#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)
{
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
@@ -80,6 +93,25 @@ int main(int argc, char **argv) {
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
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::InputRegressionAccess::setup(app);
@@ -629,6 +661,49 @@ int main(int argc, char **argv) {
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");
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");
// Mnemonics only target visible controls; show without taking foreground focus.
ShowWindow(app.settingsWindow,SW_SHOWNOACTIVATE);
@@ -774,6 +849,14 @@ int main(int argc, char **argv) {
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
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);
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
ShowWindow(autoOptions,SW_HIDE);
+108 -1
View File
@@ -9,6 +9,7 @@ namespace ks
{
struct InputRegressionAccess
{
static bool hasLast(App &app) { return app.last.has_value(); }
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
@@ -28,6 +29,8 @@ int pasteBehavior = 0;
int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0;
int droppedSelectionKeys = 0;
bool trimCopiedSpaces = false;
int trimmedCopies = 0;
bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false;
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;
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 (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
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",
"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::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
pasteBehavior = 2;
prepare(L"ghbdtn", true);
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()));
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
"unexpected editor content records readback mismatch");
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
pasteBehavior = 0;
prepare(L"привет", true);
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"SelectPrevious") != details.npos,
"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();
break;
}
@@ -853,7 +960,7 @@ int main(int argc, char **argv)
field =
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000;
deadline = GetTickCount64() + 60000;
SetWindowSubclass(field, observeEnter, 1, 0);
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
stage = 20;
+20 -1
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,
"Ctrl+wheel zoom keeps image point under cursor");
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;
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
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}};
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::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");
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);
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);