diff --git a/CMakeLists.txt b/CMakeLists.txt index 5139ba4..6a25e54 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.16 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.17 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index a9bd918..2b74930 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,11 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin for shapes. **Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object: move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new -stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and +stroke over an existing object. Hold **Shift** while drawing to constrain the current segment +to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke. +While holding Shift, moving more than 50 image pixels away from the current line selects a new +direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and +released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 4492555..4eec2b7 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 6957701..ef6a555 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,19 +1,15 @@ # Latest Windows release -## v0.6.16 — 2026-10-30 +## v0.6.17 — 2026-10-30 ## Changes -- Fix repeated offers to install the same release: the runtime updater version now matches the packaged executable version. -- Add regression checks comparing updater, executable and CMake versions, and rejecting the already-installed modern release. -- Preserve typing that arrives while a snippet is being expanded. -- Add `{enter}` to press Enter after insertion and cursor placement, with an Insert Enter marker button. Use `{{enter}}` for literal text. -- Suppress automatic correction during snippet expansion and buffered typing, without changing the global setting. Normal typing resumes automatic correction afterward. -- Restore the caret when snippet verification fails and record snippet failures in diagnostic logs. - -Includes the earlier automatic-correction and log-viewer improvements. Existing modern releases update directly to this version without installing intermediate releases. +- Hold Shift with the screenshot pencil to draw horizontal, vertical or 45-degree diagonal segments. Release Shift to continue freehand within the same stroke. +- Move more than 50 image pixels away from the active line to change direction while preserving earlier segments. Smooth diagonal gestures use the actual mouse trajectory instead of producing stair steps. +- Keep diagonal strokes inside image boundaries without changing their angle. Ctrl retains its existing draw-over-objects behavior; undo/redo treats the complete stroke as one object. +- Disable snippet fields, Delete and marker insertion until a snippet is selected or added. Deleting the selected snippet clears and disables the editor. ## Validation -223 native regression checks, 472 input/UI checks, screenshot editor and notification registration checks, 48 updater checks and updater progress checks. Targeted native integration checks cover continuous snippet typing, clipboard restoration, actual Enter key events, punctuation preservation, suppression of automatic correction and its resumption afterward. The updated development build was also confirmed by the user in Notepad. The broader integration suite showed intermittent failures during earlier interactive runs; it is not claimed as fully passing. +Passed 223 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks, updater progress checks, and 45 isolated configurator process checks. Screenshot UI coverage includes smooth diagonal gestures in all four directions, direction changes, image boundaries, Shift transitions, Ctrl behavior and undo/redo. Configurator checks cover disabled snippet editing, Add, Delete, reselection and preservation of snippet content. **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 and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index f517572..a1a2708 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -65c5d4c41c0b062eab29e5c100fd5cbff3839d808907935160cf7f5b904af6b7 KSwitcher-win-x64.zip +d5813a42105a2aebb5b6ef414c0546a44f18e4d7f3d45e0f32830097781b9677 KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 010c538..037b3a3 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -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,16,0 - PRODUCTVERSION 0,6,16,0 + FILEVERSION 0,6,17,0 + PRODUCTVERSION 0,6,17,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.16" + VALUE "FileVersion", "0.6.17" END END BLOCK "VarFileInfo" diff --git a/src/screenshot.cpp b/src/screenshot.cpp index d3568f0..748ef0c 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -178,6 +178,11 @@ struct Capture COLORREF currentColor=RGB(225,42,50); COLORREF customColors[16]{}; Mark stroke{}; + bool strokeConstrained{}; + size_t strokeAnchor{}; + int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals. + bool strokeAxisSettled{}; + std::vector strokeMotion; POINT start{}, end{}; RECT selection{}, view{}; std::vector marks; @@ -250,7 +255,7 @@ void updateStatus(Capture &c) else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection"; else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit"; else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer"; - else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer"; + else if(c.freehand) text=L"Freehand (F): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects"; else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer"; else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot"; if(text!=c.statusText) { @@ -841,7 +846,7 @@ void edit(HWND w, Capture &c) InitCommonControlsEx(&statusControls); CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS, 0,0,0,0,w,(HMENU)50,c.app.instance,nullptr); - const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object", + const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object", L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"}; static_assert(std::size(labels)==std::size(ToolIds)); auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX, @@ -1045,6 +1050,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0; case WM_KEYDOWN: + if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) { + c->strokeConstrained=true; + c->strokeAnchor=c->stroke.points.size()-1; + c->strokeAxis=0; + c->strokeAxisSettled=false; + c->strokeMotion={c->stroke.points.back()}; + } if (wp==VK_ESCAPE) { if(!c->editing) DestroyWindow(w); // Cancel region selection only. return 0; @@ -1100,6 +1112,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) } } return 0; + case WM_KEYUP: + if(wp==VK_SHIFT) c->strokeConstrained=false; + return 0; case WM_LBUTTONDBLCLK: if(c->editing && !c->dragging) { POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)}; @@ -1156,6 +1171,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor; c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds; c->stroke.points.push_back(p); + c->strokeConstrained=false; } c->dragging=true; SetCapture(w); } @@ -1193,6 +1209,63 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None) c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height); if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) { + bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000)); + if(constrained) { + if(!c->strokeConstrained) { + c->strokeAnchor=c->stroke.points.size()-1; + c->strokeAxis=0; + c->strokeAxisSettled=false; + c->strokeMotion={c->stroke.points.back()}; + } + auto anchor=c->stroke.points[c->strokeAnchor]; + // Measure the actual mouse trajectory, not its snapped projection. + auto raw=c->end; + if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y) + c->strokeMotion.push_back(raw); + while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 || + std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16)) + c->strokeMotion.erase(c->strokeMotion.begin()); + auto chooseAxis=[](POINT from,POINT to) { + LONG dx=to.x-from.x,dy=to.y-from.y; + constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees) + if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1; + if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2; + return (dx<0)==(dy<0)?3:4; + }; + auto distance=[](int axis,POINT from,POINT to) { + double dx=to.x-from.x,dy=to.y-from.y; + return axis==1?std::abs(dy):axis==2?std::abs(dx): + std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0); + }; + if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) { + c->strokeAxis=chooseAxis(anchor,c->end); + // A one-pixel first movement must not lock a diagonal gesture to an axis. + c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8; + } + else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) { + // Keep the completed segment and turn at its endpoint. + auto corner=c->stroke.points.back(); + int nextAxis=chooseAxis(c->strokeMotion.front(),raw); + if(nextAxis!=c->strokeAxis) { + c->strokeAnchor=c->stroke.points.size()-1; + anchor=corner; + c->strokeAxis=nextAxis; + } + } + if(c->strokeAxis==1) c->end.y=anchor.y; + else if(c->strokeAxis==2) c->end.x=anchor.x; + else if(c->strokeAxis>=3) { + int sign=c->strokeAxis==3?1:-1; + LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0); + LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1); + LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y; + delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY)); + c->end={anchor.x+delta,anchor.y+sign*delta}; + } + // Only the current segment is a preview; earlier corners stay fixed. + c->stroke.points.resize(c->strokeAnchor+1); + } + c->strokeConstrained=constrained; auto last=c->stroke.points.back(); if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end); c->stroke.to=c->end; diff --git a/src/ui.cpp b/src/ui.cpp index 0c86e24..f9e1880 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1375,6 +1375,14 @@ struct SnippetView std::vector snippets; int selection = -1; }; +void updateSnippetEditor(HWND w, SnippetView *v) +{ + bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size(); + value(w, 302, selected ? v->snippets[v->selection].trigger : L""); + value(w, 303, selected ? v->snippets[v->selection].text : L""); + for (int id : {302, 303, 311, 312, 313}) + EnableWindow(GetDlgItem(w, id), selected); +} void fillSnippets(HWND w, SnippetView *v) { SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0); @@ -1382,6 +1390,7 @@ void fillSnippets(HWND w, SnippetView *v) SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str()); if (v->selection >= 0) SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0); + updateSnippetEditor(w, v); } void collectSnippet(HWND w, SnippetView *v) { @@ -1418,11 +1427,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { collectSnippet(w, v); v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0); - if (v->selection >= 0) - { - value(w, 302, v->snippets[v->selection].trigger); - value(w, 303, v->snippets[v->selection].text); - } + updateSnippetEditor(w, v); return 0; } if (id == 310) @@ -1431,8 +1436,6 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) v->snippets.push_back({L";new", L""}); v->selection = (int)v->snippets.size() - 1; fillSnippets(w, v); - value(w, 302, L";new"); - value(w, 303, L""); SetFocus(GetDlgItem(w, 302)); return 0; } @@ -1443,13 +1446,13 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) v->snippets.erase(v->snippets.begin() + v->selection); v->selection = -1; fillSnippets(w, v); - value(w, 302, L""); - value(w, 303, L""); + SetFocus(GetDlgItem(w, 301)); } return 0; } if (id == 312 || id == 313) { + if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0; SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}")); return 0; } diff --git a/src/update.h b/src/update.h index 90ce818..3e21eb1 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.16"; +inline constexpr wchar_t AppVersion[] = L"0.6.17"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; diff --git a/tests/configuration.ps1 b/tests/configuration.ps1 index c87f3d3..d813e08 100644 --- a/tests/configuration.ps1 +++ b/tests/configuration.ps1 @@ -18,6 +18,7 @@ public static class ConfigurationTestUi { [DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text); [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title); [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id); + [DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp); [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp); [DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text); @@ -127,6 +128,29 @@ try { Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings' $config=Launch 'KSwitcher.Config.exe' '--snippets' $snippets=Window 'KSwitcher.Snippets' + $null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero) + foreach($id in @(302,303,311,312,313)) { + Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection" + } + $null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero) + foreach($id in @(302,303,311,312,313)) { + Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing' + } + $null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet') + $null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero) + $null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero) + foreach($id in @(302,303,311,312,313)) { + Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing' + } + $null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero) + $snippetText=[Text.StringBuilder]::new(128) + $null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText) + Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet' + $null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero) + $null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero) + Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing' + $null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText) + Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content' $second=Launch 'KSwitcher.Config.exe' '' Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process' $settings=Window 'KSwitcher.Settings' diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index b4513e5..e5b53c3 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -24,6 +24,105 @@ int main() require(c.pencilCursor!=nullptr,"pencil drawing cursor loads"); require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor, "new screenshot editor starts with the pencil tool"); + { + BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys); + std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys); + auto mouse=[&](UINT message,POINT p,bool shift=false) { + int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width); + int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height); + SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y)); + }; + mouse(WM_LBUTTONDOWN,{40,40},true); + mouse(WM_MOUSEMOVE,{100,50},true); + require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally"); + mouse(WM_MOUSEMOVE,{100,90},true); + require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2, + "50 pixel deviation keeps the current axis"); + mouse(WM_MOUSEMOVE,{100,91},true); + require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40, + "51 pixel deviation turns vertically and preserves the horizontal segment"); + mouse(WM_MOUSEMOVE,{70,140}); + require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4, + "releasing Shift resumes freehand in the same stroke"); + SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0); + mouse(WM_MOUSEMOVE,{140,150},true); + require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70, + "pressing Shift mid-stroke anchors at the last drawn point"); + SendMessageW(window,WM_KEYUP,VK_SHIFT,0); + SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0); + mouse(WM_MOUSEMOVE,{150,200},true); + mouse(WM_LBUTTONUP,{150,210},true); + SetKeyboardState(savedKeys); + require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210, + "repressing Shift without mouse movement starts a new segment and constrains mouse-up"); + SendMessageW(window,WM_COMMAND,12,0); + require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke"); + SendMessageW(window,WM_COMMAND,18,0); + require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry"); + c.marks.clear(); c.selected=-1; + for(int sx:{-1,1}) for(int sy:{-1,1}) { + mouse(WM_LBUTTONDOWN,{160,120},true); + mouse(WM_MOUSEMOVE,{160+sx,120},true); + for(int step=2;step<=90;++step) + mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true); + require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90, + "smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs"); + mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true); + c.marks.clear(); c.selected=-1; + mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true); + mouse(WM_MOUSEMOVE,{160,120-sy*70},true); + for(int step=1;step<=90;++step) + mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true); + require(c.stroke.points.size()==3 && c.strokeAxis>=3 && + std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y), + "smooth turn from horizontal into diagonal uses raw mouse direction without stairs"); + mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true); + c.marks.clear(); c.selected=-1; + } + for(int sx:{-1,1}) for(int sy:{-1,1}) { + mouse(WM_LBUTTONDOWN,{160,120},true); + mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true); + require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55, + "Shift snaps to exact 45 degree diagonals in all four directions"); + mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true); + c.marks.clear(); c.selected=-1; + } + mouse(WM_LBUTTONDOWN,{40,40},true); + mouse(WM_MOUSEMOVE,{100,40},true); + mouse(WM_MOUSEMOVE,{160,100},true); + require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 && + c.stroke.points[1].x==100 && c.stroke.points[1].y==40, + "large deviation turns a horizontal segment diagonally without losing its corner"); + mouse(WM_MOUSEMOVE,{200,100},true); + require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3, + "small perpendicular deviation keeps the diagonal axis"); + mouse(WM_MOUSEMOVE,{260,120},true); + require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4, + "diagonal deviation beyond 50 pixels turns back to horizontal"); + mouse(WM_LBUTTONUP,{260,120},true); + c.marks.clear(); c.selected=-1; + for(int sx:{-1,1}) for(int sy:{-1,1}) { + POINT start{sx>0?310L:9L,120}; + mouse(WM_LBUTTONDOWN,start,true); + mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true); + mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true); + auto end=c.stroke.to; + require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 && + std::abs(end.x-start.x)==std::abs(end.y-start.y), + "diagonal stays at 45 degrees and inside image boundaries"); + mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true); + c.marks.clear(); c.selected=-1; + } + keys[VK_CONTROL]=0x80; SetKeyboardState(keys); + mouse(WM_LBUTTONDOWN,{40,40}); + mouse(WM_MOUSEMOVE,{100,70}); + mouse(WM_LBUTTONUP,{120,90}); + SetKeyboardState(savedKeys); + require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 && + c.marks[0].to.x==120 && c.marks[0].to.y==90, + "Ctrl keeps its existing drawing behavior without constraining the pencil"); + c.marks.clear(); c.selected=-1; + } for(int size:{32,48,64,96}) { auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0); ICONINFO info{};