Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbf20e8270 | |||
| b8bb14a985 |
@@ -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
|
||||
|
||||
Binary file not shown.
+11
-10
@@ -1,17 +1,18 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.3 — 2026-10-29
|
||||
## v0.6.5 — 2026-10-29
|
||||
|
||||
- Hide the fullscreen capture selection before submitting a notification when automatic editor opening is disabled. Previously the selection remained visible during submission, which could suppress the banner under Windows fullscreen notification rules.
|
||||
- Default to capture → notification → click → editor. **Open editor after screen capture** is now off by default; explicitly saved preferences are preserved.
|
||||
- Retain the editor fallback for immediate notification errors.
|
||||
- Check for updates in the background every 6 hours after the startup check. Notify once per version per session, retry after network failures at the next interval, and install only after confirmation.
|
||||
## Changes
|
||||
- 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.
|
||||
|
||||
Verified: 176 native checks, 403 input/layout checks at 240 DPI, screenshot editor UI integration,
|
||||
isolated notification registration checks, and 45 updater checks. The new UI regression test failed
|
||||
before the fix and passes afterward: the selection is hidden at notification submission.
|
||||
Banner behavior still needs confirmation on the affected laptop. Windows Do Not Disturb can
|
||||
send notifications directly to the notification center; asynchronous toast failures are not yet monitored.
|
||||
## 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, 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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
a43786b06878da0f069dbad0fa29587a736dd2d8904c7bc991f5cf0c0570a16f KSwitcher-win-x64.zip
|
||||
bea45fdb33657fdfd5b065cdf10a3c358febdd5e895672dd106ec5ef2dd16c9a 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,3,0
|
||||
PRODUCTVERSION 0,6,3,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.3"
|
||||
VALUE "FileVersion", "0.6.5"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+126
-14
@@ -3,6 +3,8 @@
|
||||
#include "../resources/resource.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <unordered_set>
|
||||
#include <windowsx.h>
|
||||
#include <commctrl.h>
|
||||
#include <commdlg.h>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -575,6 +578,15 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
||||
{
|
||||
if((c.arrow || c.highlight || c.freehand || c.textTool) && (GetKeyState(VK_CONTROL)&0x8000)) return {-1,MarkPart::None};
|
||||
double tolerance=double(std::max(6,c.unit/6))*c.image->width/std::max(1L,c.view.right-c.view.left);
|
||||
// Badges are painted above connectors. Their visible bodies must also win
|
||||
// hit testing, even when an attached connector is currently selected.
|
||||
for(int i=int(c.marks.size())-1;i>=0;--i) {
|
||||
const auto &m=c.marks[i];
|
||||
if(m.number && std::hypot(double(p.x-m.from.x),double(p.y-m.from.y))<=m.radius) {
|
||||
auto part=hitScreenshotMark(m,p,tolerance);
|
||||
return {i,part};
|
||||
}
|
||||
}
|
||||
if(c.selected>=0)
|
||||
{
|
||||
const auto &m=c.marks[c.selected];
|
||||
@@ -883,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;
|
||||
@@ -936,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)
|
||||
@@ -1126,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);
|
||||
@@ -1353,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<width?16:-16;
|
||||
LONG dy=bounds.bottom+16<height?16:-16;
|
||||
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
|
||||
}
|
||||
namespace {
|
||||
void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
||||
{
|
||||
static unsigned long long next=1;
|
||||
for(const auto &m:marks) next=std::max(next,m.id+1);
|
||||
std::unordered_set<unsigned long long> 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;i<segments;++i) {
|
||||
POINT a=m.points.size()>1?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<best) { best=distance; link.u=(i+t)/segments; }
|
||||
}
|
||||
return best<=tolerance+m.thickness/2.0;
|
||||
}
|
||||
}
|
||||
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
||||
const std::vector<ScreenshotMark> &marks, double tolerance)
|
||||
std::vector<ScreenshotMark> &marks, double tolerance)
|
||||
{
|
||||
auto &link=start?arrow.fromNumber:arrow.toNumber;
|
||||
link=0;
|
||||
if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return;
|
||||
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)
|
||||
{
|
||||
link=it->number;
|
||||
break;
|
||||
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<unsigned long long> pending{id}; std::unordered_set<unsigned long long> 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<ScreenshotMark> &marks)
|
||||
{
|
||||
assignMarkIds(marks);
|
||||
auto badge=[&](int number) -> const ScreenshotMark * {
|
||||
if(number) for(const auto &m:marks) if(m.number==number) return &m;
|
||||
return nullptr;
|
||||
@@ -1383,9 +1477,21 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &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;
|
||||
std::vector<int> state(marks.size());
|
||||
std::function<void(size_t)> 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;i<marks.size();++i) if(marks[i].id==link.target) {
|
||||
if(state[i]==1) { link={}; return; } // Reject cyclic dependencies.
|
||||
resolve(i); point=objectAnchor(marks[i],link); return;
|
||||
}
|
||||
link={}; // Deleted targets leave endpoints at their last position.
|
||||
};
|
||||
attach(m.fromLink,m.from); attach(m.toLink,m.to);
|
||||
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
||||
if(!from) m.fromNumber=0;
|
||||
if(!to) m.toNumber=0;
|
||||
@@ -1394,6 +1500,9 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||
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;i<marks.size();++i) resolve(i);
|
||||
}
|
||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||
{
|
||||
@@ -1461,6 +1570,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
POINT position, int width, int height, bool snapRotation)
|
||||
{
|
||||
auto m=original;
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||
if(position.x==origin.x && position.y==origin.y) return m;
|
||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||
@@ -1496,6 +1606,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
||||
else if(part==MarkPart::Body)
|
||||
{
|
||||
m.fromNumber=m.toNumber=0;
|
||||
m.fromLink={}; m.toLink={};
|
||||
auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
|
||||
LONG low=-first,high=LONG(extent-1)-last;
|
||||
if(low>high && m.isText()) {
|
||||
@@ -1515,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));
|
||||
|
||||
+7
-1
@@ -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<ScreenshotMark> &);
|
||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||
const std::vector<ScreenshotMark> &, double tolerance);
|
||||
std::vector<ScreenshotMark> &, 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<ScreenshotMark> &, int width, int height);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.3";
|
||||
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
|
||||
|
||||
@@ -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<ks::ScreenshotMark> 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<ks::ScreenshotMark> 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<ks::ScreenshotMark> 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);
|
||||
|
||||
@@ -94,6 +94,56 @@ int main()
|
||||
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
|
||||
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
|
||||
c.hoveredPart=ks::MarkPart::None;
|
||||
// Drag the two badges in turn, including a click near the attached endpoint.
|
||||
for(bool freehand : {false,true}) for(auto ends : {ks::LineEnds::End,ks::LineEnds::None}) {
|
||||
c.marks={{{60,80},{60,80},1},{{230,80},{230,80},2},{{60,80},{230,80},0}};
|
||||
auto &connector=c.marks[2]; connector.fromNumber=1; connector.toNumber=2;
|
||||
connector.ends=ends; connector.freehand=freehand;
|
||||
if(freehand) connector.points={{60,80},{140,80},{230,80}};
|
||||
ks::resolveScreenshotLinks(c.marks); c.selected=-1; c.selectedLabel=false;
|
||||
auto mouse=[&](UINT message,POINT p) {
|
||||
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||
LONG 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,MAKELPARAM(x,y));
|
||||
};
|
||||
for(int badge : {0,1,0,1}) {
|
||||
auto origin=c.marks[badge].from;
|
||||
// The second badge's inner edge overlaps the connector endpoint hit area.
|
||||
if(badge==1) origin.x-=12;
|
||||
mouse(WM_LBUTTONDOWN,origin);
|
||||
require(c.selected==badge && c.part==ks::MarkPart::Body,"click on linked badge selects badge instead of connector endpoint");
|
||||
mouse(WM_MOUSEMOVE,{origin.x,origin.y+20});
|
||||
mouse(WM_LBUTTONUP,{origin.x,origin.y+20});
|
||||
require(c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,"alternating badge drags preserve both connector links");
|
||||
auto start=c.marks[0].from,end=c.marks[1].from;
|
||||
require(std::abs(std::hypot(double(c.marks[2].from.x-start.x),double(c.marks[2].from.y-start.y))-15)<1 &&
|
||||
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;
|
||||
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
|
||||
}
|
||||
std::vector<ks::Mark> nudgeMarks;
|
||||
nudgeMarks.push_back({{60,60},{60,60},1});
|
||||
nudgeMarks.push_back({{60,60},{120,100},0});
|
||||
|
||||
Reference in New Issue
Block a user