Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 004fee4976 | |||
| b08c5ff8ff | |||
| b6e7bda220 | |||
| 89e506faae | |||
| c78b51d341 | |||
| 0184b837f4 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.29 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,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.
|
||||
@@ -685,3 +698,7 @@ Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
||||
legacy version tag: clients that have not installed the bridge would select it.
|
||||
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
-18
@@ -1,29 +1,19 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.29 — 2026-11-02
|
||||
## v0.6.34 — 2026-11-04
|
||||
|
||||
## Changes
|
||||
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item.
|
||||
- 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.
|
||||
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action.
|
||||
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management.
|
||||
- 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 build passed isolated checks for the new button, cancellation, confirmed deletion of pinned/search-hidden entries and persistence after reloading the history store. 48 updater regression checks passed; all executable versions and release-manifest hashes were verified. The full desktop-interaction suite was not rerun for this focused patch.
|
||||
The full `build.ps1 -Stage -Test` run passed: 105 sync checks, shared wheel batching, 483 native regressions, 551 input/UI checks at 96 DPI, screenshot editor UI checks (including free-movement cursor feedback across drawing tools and horizontal/vertical thickness cursors), PNG saving, isolated screenshot registration, 48 updater regressions and updater progress UI checks. All three executable versions and all 12 package manifest file sizes/SHA-256 hashes were verified; the package contains no personal data.
|
||||
|
||||
## Known limitations
|
||||
Existing asynchronous-editor selection/clipboard limitations remain; this release does not fix the reported ChatGPT selection mismatch. Dictionary typo correction and OpenBao synchronization remain experimental as documented in v0.6.28.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
## Upgrade
|
||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
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 @@
|
||||
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 KSwitcher-win-x64.zip
|
||||
c7f004ecd9d0f1032356060191ca3ca9fca458df434d3e5908c5d9b038f95acc KSwitcher-win-x64.zip
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,29,0
|
||||
PRODUCTVERSION 0,6,29,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.29"
|
||||
VALUE "FileVersion", "0.6.34"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+111
-3
@@ -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
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.29";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.34";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user