Compare commits

...

4 Commits

16 changed files with 380 additions and 40 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.30 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.34 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+16 -3
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
@@ -689,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.
+9 -19
View File
@@ -1,29 +1,19 @@
# Latest Windows release
## v0.6.30 — 2026-11-02
## v0.6.34 — 2026-11-04
## Changes
- A manual paste confirmed unsuccessful by readback no longer records the expected replacement as a previous result or switches the Windows layout. This prevents repeated commands from searching for text that was never inserted.
- A previous-result verification mismatch clears stale repeat-conversion state.
- A writable native Windows EDIT field may receive one guarded paste retry: readback must equal the original text, and fresh native checks must confirm the exact original selection, unchanged focus and input context. Other controls, protected/read-only fields and unknown/changed selections are not retried.
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
- 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
The final isolated conversion integration run passed: permanently blocked paste, unexpected editor content, clearing fictional previous results, ignored-first-paste recovery, clipboard restoration, repeat conversion and partial-selection recovery. Typo integration also passed, including Undo and uninterrupted typing. 541 input/UI checks (240 DPI), 105 sync/protocol checks, 483 native regressions and 48 updater checks passed. All release executable versions and manifest hashes were verified. Earlier interactive attempts were interrupted by concurrent input or a transient clipboard-restoration assertion; the final isolated rerun passed.
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
The cause of every ignored paste is not established. This release does not claim to solve all asynchronous selection failures in ChatGPT/browser editors. Keep Write log enabled and send complete operation details if the issue recurs. Native retry is bounded to one attempt; it is not a general retry policy for other applications. All existing dictionary-correction and synchronization limitations remain.
**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.
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 @@
7e557851ab68a37a8211ea39de41e79314f460ef8ac6471be723d0cfe03cfba9 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,30,0
PRODUCTVERSION 0,6,30,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.30"
VALUE "FileVersion", "0.6.34"
END
END
BLOCK "VarFileInfo"
+61 -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);
@@ -1341,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" :
@@ -1357,7 +1404,7 @@ 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())
@@ -1388,7 +1435,17 @@ void App::transform(int command, int snippet)
return;
}
}
else reason = L"Paste not verified";
else
{
reason = L"Paste not verified";
if (requireReadback)
{
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
return;
}
}
}
else verification = L"context-changed-after-paste";
}
@@ -1597,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];
+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, 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)
@@ -1338,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)
@@ -1403,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)
@@ -1454,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);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.30";
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);
+93 -1
View File
@@ -29,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)
@@ -77,6 +79,37 @@ 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)
@@ -464,6 +497,65 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
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;
}
@@ -868,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);