diff --git a/README.md b/README.md index 8738632..d41f4ba 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,13 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number gets the smallest available positive number, just like a newly placed number: gaps left by deletion are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links. +Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks, +and other arrows or strokes. Drop an endpoint onto the target; use **Ctrl+drag** to start a new +connector over an existing object. Attached endpoints follow movement and resizing; text attachments +also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line. +Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector +intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target +leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected. The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action and its keyboard shortcut. Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index a8a8a20..ba7aa04 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 a442979..cdcfa2b 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,13 +1,18 @@ # Latest Windows release -## v0.6.4 — 2026-10-29 +## v0.6.5 — 2026-10-29 ## Changes -- Fix accidental arrow/line detachment when dragging two linked numbered markers in turn. Clicking a visible marker now selects the marker instead of a connector endpoint underneath it, including when the connector is selected. -- Preserve intentional endpoint dragging outside the marker. +- Preserve endpoint links when selecting a connector or adding/editing its caption through double-click, F2 or the caption button. A click without movement no longer detaches the connector. +- Attach arrow and line endpoints to rectangles, circles, ellipses, text blocks, other arrows/lines, and freehand/highlight strokes, in addition to numbered markers. +- Keep attachments aligned as targets move or resize; text attachments also follow rotation. Arrow-to-arrow chains resolve in dependency order. +- Preserve links through Undo/Redo. Deleted targets leave endpoints at their last position; clones start without links. Self-links and circular dependencies are rejected. + +## Usage +Drop a connector endpoint onto the target. Use **Ctrl+drag** to start a new connector over an existing object. Moving a whole connector detaches both of its endpoints; dragging one endpoint detaches only that end. ## Validation -176 native checks, 403 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. The new UI regression test fails before the fix and passes afterward, covering alternating marker drags with arrows, lines and freehand connectors, both endpoint links, and endpoint selection outside markers. +176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. Coverage includes caption editing, shape-arrow-arrow chains, movement, resize, text rotation, deletion, cloning, cycle rejection, and Undo/Redo. **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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 3fbf93b..3161f93 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -da86748a476deec9323c39e119c89dc008e2fe62691b207eb3b0f3bb0da32cf6 KSwitcher-win-x64.zip +bea45fdb33657fdfd5b065cdf10a3c358febdd5e895672dd106ec5ef2dd16c9a KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 0b7619a..3dbf068 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,4,0 - PRODUCTVERSION 0,6,4,0 + FILEVERSION 0,6,5,0 + PRODUCTVERSION 0,6,5,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.4" + VALUE "FileVersion", "0.6.5" END END BLOCK "VarFileInfo" diff --git a/src/screenshot.cpp b/src/screenshot.cpp index 45eac06..54ec282 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -3,6 +3,8 @@ #include "../resources/resource.h" #include #include +#include +#include #include #include #include @@ -197,7 +199,7 @@ void updateStatus(Capture &c) else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps"; else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size"; else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter"; - else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Arrow endpoints can snap to numbered markers"; + else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows"; else if(c.selected>=0) { const auto &m=c.marks[c.selected]; if(c.selectedLabel || part==MarkPart::Label) @@ -274,6 +276,7 @@ void finishInlineText(HWND w,Capture &c,bool commit) } if(!empty) c.textDefaults=c.textDraft; } + if(commit) resolveScreenshotLinks(c.marks); c.textIndex=-1; c.editingLabel=false; InvalidateRect(w,nullptr,FALSE); } @@ -892,6 +895,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if(chooseTextFont(w,draft)) { c->remember(c->marks); c->marks[c->selected]=draft; c->textDefaults=draft.isLine()?screenshotArrowLabel(draft):draft; + resolveScreenshotLinks(c->marks); } } else chooseTextFont(w,c->textDefaults); SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0; @@ -945,7 +949,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) auto &mark=c->marks[c->selected]; int &value=mark.isText()?mark.fontSize:mark.thickness; int next=std::clamp(value+(LOWORD(wp)==16?1:-1),mark.isText()?6:mark.isHighlight()?4:1,mark.isText()?200:mark.isHighlight()?100:30); - if(next!=value) { c->remember(c->marks); value=next; } + if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); } } if (LOWORD(wp)==13) { copy(w,*c); return 0; } if (LOWORD(wp)==12 || LOWORD(wp)==18) @@ -1135,7 +1139,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if (!c->editing) { finishSelection(w,*c); return 0; } else if(c->part!=MarkPart::None) { - if(c->part==MarkPart::Start || c->part==MarkPart::End) + if((c->part==MarkPart::Start || c->part==MarkPart::End) && + (c->start.x!=c->end.x || c->start.y!=c->end.y)) { auto &mark=c->marks[c->selected]; linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6); @@ -1362,27 +1367,107 @@ ScreenshotMark cloneScreenshotMark(const ScreenshotMark &original,const std::vec { auto result=original; result.fromNumber=result.toNumber=0; + result.id=0; result.fromLink={}; result.toLink={}; if(result.number) result.number=nextScreenshotNumber(marks); auto bounds=markBounds(result); LONG dx=bounds.right+16 &marks, double tolerance) +namespace { +void assignMarkIds(std::vector &marks) { - auto &link=start?arrow.fromNumber:arrow.toNumber; - link=0; - if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return; - for(auto it=marks.rbegin();it!=marks.rend();++it) - if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) - { - link=it->number; - break; + static unsigned long long next=1; + for(const auto &m:marks) next=std::max(next,m.id+1); + std::unordered_set used; + for(auto &m:marks) { + if(!m.id || !used.insert(m.id).second) { m.id=next++; used.insert(m.id); } + } +} +POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link) +{ + if(m.isText() || m.shape!=ScreenshotShape::None) { + auto r=m.isText()?plainTextBounds(m):region(m.from,m.to); + POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))}; + return m.isText()?rotateTextPoint(m,p):p; + } + size_t segments=m.points.size()>1?m.points.size()-1:1; + double along=std::clamp(link.u,0.0,1.0)*segments; + size_t i=std::min(size_t(along),segments-1); double t=along-i; + POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to; + return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))}; +} +bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link) +{ + link={m.id,0,0}; + if(m.isText() || m.shape!=ScreenshotShape::None) { + auto r=m.isText()?plainTextBounds(m):region(m.from,m.to); + POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop; + double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top); + double x=(p.x-r.left)/w,y=(p.y-r.top)/h; + bool inside=x>=0 && x<=1 && y>=0 && y<=1; + if(m.shape==ScreenshotShape::Circle || m.shape==ScreenshotShape::Ellipse) { + double dx=2*x-1,dy=2*y-1,length=std::hypot(dx,dy); + inside=length<=1; + if(length<0.001) { dx=1; dy=0; length=1; } + link.u=(1+dx/length)/2; link.v=(1+dy/length)/2; + } else { + link.u=std::clamp(x,0.0,1.0); link.v=std::clamp(y,0.0,1.0); + if(inside) { + double edges[]{x*w,(1-x)*w,y*h,(1-y)*h}; + auto side=std::min_element(edges,edges+4)-edges; + if(side<2) link.u=side==0?0:1; else link.v=side==2?0:1; + } } + auto anchor=objectAnchor(m,link); + return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance; + } + double best=1e100; + size_t segments=m.points.size()>1?m.points.size()-1:1; + for(size_t i=0;i1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to; + double dx=b.x-a.x,dy=b.y-a.y,length=dx*dx+dy*dy; + double t=length?std::clamp(((drop.x-a.x)*dx+(drop.y-a.y)*dy)/length,0.0,1.0):0; + double distance=std::hypot(drop.x-a.x-t*dx,drop.y-a.y-t*dy); + if(distance &marks, double tolerance) +{ + assignMarkIds(marks); + auto &number=start?arrow.fromNumber:arrow.toNumber; + auto &link=start?arrow.fromLink:arrow.toLink; + number=0; link={}; + if(!arrow.isLine()) return; + // Number badges are drawn above other objects. + for(auto it=marks.rbegin();it!=marks.rend();++it) + if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) { + number=it->number; return; + } + auto wouldCycle=[&](unsigned long long id) { + std::vector pending{id}; std::unordered_set seen; + while(!pending.empty()) { + auto current=pending.back(); pending.pop_back(); + if(arrow.id && current==arrow.id) return true; + if(!current || !seen.insert(current).second) continue; + for(const auto &m:marks) if(m.id==current) { + pending.push_back(m.fromLink.target); pending.push_back(m.toLink.target); break; + } + } + return false; + }; + for(auto it=marks.rbegin();it!=marks.rend();++it) { + if(it->number || &*it==&arrow || (arrow.id && it->id==arrow.id)) continue; + ScreenshotLink candidate; + if(findObjectAnchor(*it,drop,tolerance,candidate) && !wouldCycle(it->id)) { link=candidate; return; } + } } void resolveScreenshotLinks(std::vector &marks) { + assignMarkIds(marks); auto badge=[&](int number) -> const ScreenshotMark * { if(number) for(const auto &m:marks) if(m.number==number) return &m; return nullptr; @@ -1392,17 +1477,32 @@ void resolveScreenshotLinks(std::vector &marks) if(length<0.001) { dx=start?1:-1; dy=0; length=1; } return {m.from.x+LONG(std::lround(m.radius*dx/length)),m.from.y+LONG(std::lround(m.radius*dy/length))}; }; - for(auto &m:marks) - { - if(m.number || m.isHighlight() || m.isText() || m.shape!=ScreenshotShape::None) continue; - auto from=badge(m.fromNumber),to=badge(m.toNumber); - if(!from) m.fromNumber=0; - if(!to) m.toNumber=0; - POINT start=from?from->from:m.from, end=to?to->from:m.to; - if(from) m.from=rim(*from,end,true); - if(to) m.to=rim(*to,start,false); - if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; } - } + std::vector state(marks.size()); + std::function resolve=[&](size_t index) { + if(state[index]) return; + state[index]=1; + auto &m=marks[index]; + if(m.isLine()) { + auto attach=[&](ScreenshotLink &link,POINT &point) { + if(!link.target) return; + for(size_t i=0;ifrom:m.from, end=to?to->from:m.to; + if(from) m.from=rim(*from,end,true); + if(to) m.to=rim(*to,start,false); + if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; } + } + state[index]=2; + }; + for(size_t i=0;ihigh && m.isText()) { @@ -1524,6 +1626,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part, else if(part==MarkPart::Start || part==MarkPart::End) { (part==MarkPart::Start?m.fromNumber:m.toNumber)=0; + (part==MarkPart::Start?m.fromLink:m.toLink)={}; auto &endpoint=part==MarkPart::Start?m.from:m.to; endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1)); endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1)); diff --git a/src/screenshot.h b/src/screenshot.h index 6152e8a..fa56f3d 100644 --- a/src/screenshot.h +++ b/src/screenshot.h @@ -17,6 +17,10 @@ enum class LineStyle { Solid, Dotted, Dashed, DashDot }; enum class LineEnds { End, Start, Both, None }; enum class ScreenshotShape { None, Rectangle, Circle, Ellipse }; struct ScreenshotOffset { double x, y; }; +struct ScreenshotLink { + unsigned long long target{}; + double u{}, v{}; // Relative boundary position, or fractional position along a path. +}; struct ScreenshotMark { POINT from, to; int number; int radius = 15; int thickness = 3; int fromNumber = 0, toNumber = 0; @@ -33,6 +37,8 @@ struct ScreenshotMark { std::wstring label; ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels. bool labelRotationManual=false; + unsigned long long id{}; + ScreenshotLink fromLink, toLink; bool isText() const { return !text.empty(); } bool isHighlight() const { return !points.empty() && !freehand; } bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; } @@ -48,7 +54,7 @@ class ScreenshotHistory { }; void resolveScreenshotLinks(std::vector &); void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop, - const std::vector &, double tolerance); + std::vector &, double tolerance); enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label }; POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16); ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector &, int width, int height); diff --git a/src/update.h b/src/update.h index 9e50848..c7f4a4d 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.4"; +inline constexpr wchar_t AppVersion[] = L"0.6.5"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; struct UpdateResult diff --git a/tests/input.cpp b/tests/input.cpp index ee1b9e9..0bb9983 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -179,6 +179,67 @@ int main(int argc, char **argv) { require(detached.fromNumber==0,"dropping away from badges remains detached"); auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100); require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints"); + for(auto shape : {ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) { + std::vector objects={{{100,50},{160,110},0},{{10,80},{100,80},0},{{10,140},{55,80},0}}; + objects[0].shape=shape; + ks::linkScreenshotEndpoint(objects[1],false,{100,80},objects,6); + ks::resolveScreenshotLinks(objects); + require(objects[1].toLink.target==objects[0].id && objects[1].to.x==100,"arrow attaches to shape boundary"); + ks::linkScreenshotEndpoint(objects[2],false,{55,80},objects,6); + ks::resolveScreenshotLinks(objects); + require(objects[2].toLink.target==objects[1].id,"arrow attaches to another arrow body"); + auto targetId=objects[0].id,arrowId=objects[1].id; + auto snapshot=objects; ks::ScreenshotHistory linksHistory; linksHistory.remember(objects); + // Reverse storage order so dependency resolution cannot rely on insertion order. + std::reverse(objects.begin(),objects.end()); + objects[2]=ks::dragScreenshotMark(objects[2],ks::MarkPart::Body,{0,0},{20,20},400,300); + ks::resolveScreenshotLinks(objects); + require(objects[1].to.x==120 && objects[1].to.y==100 && objects[0].to.x==65 && objects[0].to.y==90, + "shape-arrow-arrow chain updates in dependency order"); + require(linksHistory.undo(objects) && objects[1].to.x==100 && objects[2].toLink.target==arrowId, + "undo restores geometry and generic links"); + require(linksHistory.redo(objects) && objects[0].to.x==65,"redo restores moved chain"); + auto resized=objects; + auto corner=resized[2].to; + resized[2]=ks::dragScreenshotMark(resized[2],ks::MarkPart::End,corner,{corner.x+20,corner.y+20},400,300); + ks::resolveScreenshotLinks(resized); + require(resized[1].to.x==120 && resized[1].to.y==110 && resized[0].to.y==95, + "resizing shapes updates both levels of attached arrows"); + ks::linkScreenshotEndpoint(objects[1],true,objects[0].from,objects,6); + require(!objects[1].fromLink.target,"cyclic arrow links are rejected"); + auto clone=ks::cloneScreenshotMark(objects[1],objects,400,300); + require(!clone.id && !clone.fromLink.target && !clone.toLink.target,"cloned connector has independent identity and no links"); + auto endpoint=objects[1].to; + objects.erase(objects.begin()+2); ks::resolveScreenshotLinks(objects); + require(!objects[1].toLink.target && objects[1].to.x==endpoint.x && objects[0].toLink.target==arrowId, + "deleting shape detaches only its dependents without moving endpoints"); + objects.push_back(snapshot[0]); objects.back().id=0; ks::resolveScreenshotLinks(objects); + require(objects.back().id!=targetId && !objects[1].toLink.target,"new objects cannot revive deleted links"); + } + { + std::vector objects={{{80,40},{80,40},0},{{10,40},{80,40},0}}; + objects[0].text=L"Target"; objects[0].fontSize=20; + auto bounds=ks::screenshotTextBounds(objects[0]); + ks::linkScreenshotEndpoint(objects[1],false,{bounds.left,(bounds.top+bounds.bottom)/2},objects,6); + ks::resolveScreenshotLinks(objects); + require(objects[1].toLink.target==objects[0].id,"arrow attaches to text block"); + auto old=objects[1].to; + objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{30,20},400,300); + ks::resolveScreenshotLinks(objects); + require(objects[1].to.x==old.x+30 && objects[1].to.y==old.y+20,"text attachment follows movement"); + objects[0].rotation=90; ks::resolveScreenshotLinks(objects); + require(objects[1].to.x!=old.x+30 || objects[1].to.y!=old.y+20,"text attachment follows rotation"); + } + for(bool freehand : {false,true}) { + std::vector objects={{{80,60},{160,80},0},{{10,140},{110,60},0}}; + objects[0].points={{80,60},{140,60},{160,80}}; objects[0].freehand=freehand; + ks::linkScreenshotEndpoint(objects[1],false,{110,60},objects,6); + ks::resolveScreenshotLinks(objects); + require(objects[1].toLink.target==objects[0].id,"arrow attaches to freehand and highlight strokes"); + objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{20,20},400,300); + ks::resolveScreenshotLinks(objects); + require(objects[1].to.x==130 && objects[1].to.y==80,"stroke attachment follows the original segment"); + } auto lastPoint=linked[2].from; linked.erase(linked.begin()); ks::resolveScreenshotLinks(linked); diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index f878d21..5afa4a1 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -120,6 +120,25 @@ int main() std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.y))-15)<1, "connector endpoints follow both badge rims"); } + // Selecting a linked connector (also the first click of a double-click) + // must not detach it before opening its caption editor. + for(int entry : {0,1,2}) { + c.selected=-1; + POINT body{110,c.marks[0].from.y}; + if(freehand) body={(c.marks[2].points[0].x+c.marks[2].points[1].x)/2, + (c.marks[2].points[0].y+c.marks[2].points[1].y)/2}; + mouse(WM_LBUTTONDOWN,body); mouse(WM_LBUTTONUP,body); + require(c.selected==2 && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2, + "clicking a connector without moving preserves both links"); + if(entry==0) { mouse(WM_LBUTTONDBLCLK,body); mouse(WM_LBUTTONUP,body); } + else if(entry==1) SendMessageW(window,WM_KEYDOWN,VK_F2,0); + else SendMessageW(window,WM_COMMAND,35,0); + require(c.inlineText && c.editingLabel,"linked connector caption opens through every entry point"); + SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Linked caption"); + ks::finishInlineText(window,c,true); + require(c.marks[2].label==L"Linked caption" && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2, + "adding or editing a caption preserves both connector links"); + } c.selected=2; require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click"); auto outside=c.marks[2].to; outside.x-=4;