Files
hrynco-kswitcher/tests/screenshot_ui.cpp
T

742 lines
53 KiB
C++

// Exercise the actual editor controls without capturing the desktop or changing the clipboard.
// Including the implementation lets this integration test drive private editor state directly.
#include "../src/screenshot.cpp"
#include <iostream>
#include "stroke_improvement.h"
int main(int argc, char **argv)
{
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
try {
testStrokeImprovement();
if(argc>1 && std::string(argv[1])=="--save-only") {
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(root);
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
c.marks={ks::Mark{{40,40},{180,140},0}};
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
auto path=root/L"Знімок.png";
const auto clipboard=GetClipboardSequenceNumber();
ks::saveToFile(nullptr,c,path);
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
auto actual=ks::decodeImage(encoded);
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
"PNG export preserves full resolution, annotations and opaque pixels");
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
c.remember(c.marks); c.marks.clear();
require(!c.copied,"editing after save marks changes unexported");
ks::save(nullptr,c);
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
c.copied=false;
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
return FALSE;
});
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
auto another=root/L"Another.png";
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
return TRUE;
});
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
c.copied=false;
bool failed=false;
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
"saving leaves no temporary files");
bool invalid=false;
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
});} catch(const std::exception &){invalid=true;}
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
CoUninitialize();
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
return 0;
}
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS); c.start={0,0}; c.end={320,240};
WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=ks::captureProc;
cls.lpszClassName=L"KSwitcher.Editor.Integration"; RegisterClassW(&cls);
auto window=CreateWindowW(cls.lpszClassName,L"Editor test",WS_POPUP,0,0,320,240,app.window,nullptr,app.instance,&c);
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
require(window!=nullptr,"editor creation");
ks::edit(window,c); ShowWindow(window,SW_HIDE);
require(GetWindow(window,GW_OWNER)==nullptr && (GetWindowLongPtrW(window,GWL_EXSTYLE)&WS_EX_APPWINDOW),
"editor is an independent Alt+Tab application window");
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
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");
{
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
SendMessageW(window,WM_KEYDOWN,'G',0);
require(c.shape==ks::ScreenshotShape::Triangle && c.arrow && !c.freehand,"G selects triangle drawing tool");
auto mouse=[&](UINT message,POINT p) {
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,MAKELPARAM(x,y));
};
mouse(WM_LBUTTONDOWN,{80,80}); mouse(WM_LBUTTONUP,{220,180});
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Triangle && c.marks[0].points.size()==4,
"triangle tool creates a closed three-vertex shape");
auto triangle=c.marks[0];
for(double angle:{0.0,37.0,90.0,-125.0}) for(int i=0;i<3;++i) {
auto original=triangle; original.rotation=angle;
auto vertices=ks::shapeOutline(original);
auto part=ks::MarkPart(int(ks::MarkPart::Vertex0)+i);
require(ks::hitScreenshotMark(original,vertices[i],4)==part,"each rotated triangle vertex has its own handle");
POINT target{vertices[i].x+13,vertices[i].y-9};
auto changed=ks::dragScreenshotMark(original,part,vertices[i],target,320,240);
auto moved=ks::shapeOutline(changed);
for(int j=0;j<3;++j) {
auto expected=j==i?target:vertices[j];
require(std::abs(moved[j].x-expected.x)<=2 && std::abs(moved[j].y-expected.y)<=2,
"dragging one vertex preserves both other world vertices after rotation");
}
require(changed.points.front().x==changed.points.back().x && changed.points.front().y==changed.points.back().y,
"triangle remains closed after editing any vertex");
}
auto vertices=ks::shapeOutline(triangle);
mouse(WM_LBUTTONDOWN,vertices[0]);
require(c.part==ks::MarkPart::Vertex0,"mouse selects apex vertex");
mouse(WM_LBUTTONUP,{vertices[0].x+20,vertices[0].y+10});
require(c.marks[0].points[0].x==triangle.points[0].x+20 && c.marks[0].points[1].x==triangle.points[1].x,
"mouse vertex drag changes only requested corner");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].points[0].x==triangle.points[0].x,"undo restores triangle vertex");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks[0].points[0].x==triangle.points[0].x+20,"redo reapplies triangle vertex edit");
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
require(GetDlgItem(window,37)!=nullptr,"trapezoid has a dedicated toolbar button");
SendMessageW(window,WM_KEYDOWN,'B',0);
require(c.shape==ks::ScreenshotShape::Trapezoid && c.arrow && !c.freehand,"B selects trapezoid tool");
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Trapezoid && c.marks[0].points.size()==5 && !c.marks[0].isHighlight(),
"trapezoid tool creates a closed shape with unequal parallel bases");
auto trapezoid=c.marks[0];
require(trapezoid.points[0].y==trapezoid.points[1].y && trapezoid.points[2].y==trapezoid.points[3].y &&
trapezoid.points[1].x-trapezoid.points[0].x<trapezoid.points[2].x-trapezoid.points[3].x,"toolbar trapezoid has parallel unequal bases");
mouse(WM_LBUTTONDOWN,trapezoid.to); mouse(WM_LBUTTONUP,{260,200});
require(c.marks[0].to.x==260 && c.marks[0].points[0].y==c.marks[0].points[1].y,"trapezoid resizes as a shape");
auto grip=ks::screenshotShapeRotationHandle(c.marks[0]);
auto rotated=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,grip,{280,135},320,240);
require(rotated.rotation!=0 && rotated.shape==ks::ScreenshotShape::Trapezoid,"trapezoid supports shape rotation");
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo trapezoid resize");
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
SendMessageW(window,WM_COMMAND,21,0);
}
{
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle})
for(bool recognized:{false,true}) {
if(kind==ks::ScreenshotShape::Triangle && !recognized) continue;
ks::Mark shape{{100,90},{200,170},0}; shape.shape=kind; shape.ends=ks::LineEnds::None;
if(kind==ks::ScreenshotShape::Circle) shape.to.y=190;
if(recognized) shape.points=kind==ks::ScreenshotShape::Triangle?
std::vector<POINT>{{150,90},{200,170},{100,170},{150,90}}:ks::shapeOutline(shape);
auto grip=ks::screenshotShapeRotationHandle(shape);
POINT center{(shape.from.x+shape.to.x)/2,(shape.from.y+shape.to.y)/2};
POINT right{center.x+center.y-grip.y,center.y};
auto rotated=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,right,320,240);
require(std::abs(rotated.rotation-90)<.01 && rotated.from.x==shape.from.x && rotated.to.y==shape.to.y,
"all native and recognized shapes rotate around their center");
auto corner=ks::rotateShapePoint(rotated,rotated.to);
require(ks::hitScreenshotMark(rotated,corner,4)==(kind==ks::ScreenshotShape::Triangle?ks::MarkPart::Vertex1:ks::MarkPart::End),"rotated resize handles remain hittable");
auto fixed=ks::rotateShapePoint(rotated,rotated.from);
auto resized=ks::dragScreenshotMark(rotated,ks::MarkPart::End,corner,{corner.x-15,corner.y+20},320,240);
auto stillFixed=ks::rotateShapePoint(resized,resized.from);
require(std::abs(stillFixed.x-fixed.x)<=1 && std::abs(stillFixed.y-fixed.y)<=1 && resized.rotation==90,
"resizing rotated shapes keeps the opposite world corner fixed");
auto snapped=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,{center.x+60,center.y-20},320,240,true);
require(std::abs(std::remainder(snapped.rotation,15))<.01,"Shift snaps shape rotation to 15 degrees");
auto cloned=ks::cloneScreenshotMark(rotated,{rotated},320,240);
require(cloned.rotation==rotated.rotation && cloned.shape==kind,"clone preserves shape rotation");
auto boundary=ks::shapeOutline(shape).front();
std::vector<ks::Mark> linked{shape,{{20,20},boundary,0}};
ks::linkScreenshotEndpoint(linked[1],false,boundary,linked,4);
require(linked[1].toLink.target!=0,"connector attaches to shape before rotation");
linked[0].rotation=90; ks::resolveScreenshotLinks(linked);
auto expected=ks::rotateShapePoint(linked[0],boundary);
require(std::abs(linked[1].to.x-expected.x)<=1 && std::abs(linked[1].to.y-expected.y)<=1,
"connector follows rotated shape boundary");
}
ks::Mark shape{{100,90},{200,170},0}; shape.shape=ks::ScreenshotShape::Rectangle;
c.marks={shape}; c.selected=0;
auto mouse=[&](UINT message,POINT p) {
auto x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
auto 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));
};
auto grip=ks::screenshotShapeRotationHandle(shape,double(c.unit)*c.image->width/(c.view.right-c.view.left));
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"shape rotation grip is distinct from thickness grip");
mouse(WM_LBUTTONDOWN,grip); mouse(WM_LBUTTONUP,{150+130-grip.y,130});
require(std::abs(c.marks[0].rotation-90)<1,"mouse rotation updates selected shape");
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].rotation==0,"undo restores original shape orientation");
SendMessageW(window,WM_COMMAND,18,0); require(std::abs(c.marks[0].rotation-90)<1,"redo restores shape rotation");
c.selected=0;
ks::Surface rotatedPixels(320,240); PatBlt(rotatedPixels.dc,0,0,320,240,WHITENESS);
auto rendered=ks::decodeImage(ks::screenshotClip(rotatedPixels.dc,320,240,c.marks));
auto pixel=(100*320+190)*4;
require(rendered.bgra[pixel+2]==GetRValue(shape.color) && rendered.bgra[pixel+1]==GetGValue(shape.color),
"export renders rotated shape at its new boundary");
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
ks::zoomCanvas(window,c,2,center);
grip=ks::screenshotShapeRotationHandle(c.marks[0],double(c.unit)*c.image->width/(c.view.right-c.view.left));
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"rotation grip works on zoomed canvas");
ks::zoomCanvas(window,c,1,center,true);
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
}
{
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
std::copy(savedKeys,savedKeys+256,keys); keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
auto fit=c.view;
SendMessageW(window,WM_KEYDOWN,VK_OEM_PLUS,0);
require(c.zoom==1.25 && c.view.right-c.view.left>fit.right-fit.left,"Ctrl+plus zooms canvas");
SendMessageW(window,WM_KEYDOWN,VK_SUBTRACT,0);
require(std::abs(c.zoom-1)<.001,"Ctrl+numpad minus zooms out");
SendMessageW(window,WM_KEYDOWN,VK_ADD,0);
SendMessageW(window,WM_KEYDOWN,VK_OEM_MINUS,0);
require(std::abs(c.zoom-1)<.001,"both keyboard plus/minus variants work");
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
ks::zoomCanvas(window,c,4,center);
POINT anchor{center.x+12,center.y+8},screen=anchor; ClientToScreen(window,&screen);
auto before=ks::imagePoint(c,anchor,false); double zoom=c.zoom;
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
require(c.zoom==zoom,"partial wheel delta waits for a full step");
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
auto after=ks::imagePoint(c,anchor,false);
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");
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));
SendMessageW(window,WM_MBUTTONUP,0,MAKELPARAM(center.x+15,center.y+10));
require(c.view.left==view.left+15 && c.view.top==view.top+10 && !c.panning,"middle drag pans enlarged canvas");
keys[VK_CONTROL]=0; SetKeyboardState(keys);
auto a=ks::imagePoint(c,center),b=ks::imagePoint(c,{center.x+30,center.y+20});
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(center.x,center.y));
SendMessageW(window,WM_LBUTTONUP,0,MAKELPARAM(center.x+30,center.y+20));
require(c.marks.size()==1 && c.marks[0].from.x==a.x && c.marks[0].to.x==b.x && c.marks[0].to.y==b.y,
"drawing after zoom and pan uses image coordinates");
auto original=c.marks[0];
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
SendMessageW(window,WM_KEYDOWN,'0',0);
require(c.zoom==1 && c.panX==0 && c.panY==0 && c.marks[0].from.x==original.from.x && c.marks[0].to.x==original.to.x,
"Ctrl+0 resets view without transforming annotations");
ks::zoomCanvas(window,c,1e6,center); require(c.zoom==16,"zoom has upper bound");
ks::zoomCanvas(window,c,1e-6,center); require(c.zoom==.1,"zoom has lower bound");
SendMessageW(window,WM_KEYDOWN,'0',0); SetKeyboardState(savedKeys);
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
}
{
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,{30,190});
for(int x=35;x<=200;x+=5) mouse(WM_MOUSEMOVE,{x,190+(x%10?2:-2)});
auto original=c.stroke.points;
mouse(WM_LBUTTONUP,c.stroke.to);
require(c.marks.size()==1,"improved stroke is one object");
require(!c.marks[0].freehand && c.marks[0].points.empty() &&
c.marks[0].shape==ks::ScreenshotShape::None && !c.marks[0].isHighlight(),
"near-straight pencil stroke becomes an editable line on mouse release");
require(c.marks[0].color==c.currentColor && c.marks[0].thickness==3 &&
c.marks[0].style==c.lineStyle && c.marks[0].ends==c.freehandEnds,
"line recognition preserves drawing style");
auto improved=c.marks.back().points;
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks.size()==1 && c.marks.back().freehand && c.marks.back().points.size()==original.size() &&
std::equal(original.begin(),original.end(),c.marks.back().points.begin(),
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;}),"first undo restores exact raw stroke");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks.empty(),"second undo removes stroke");
SendMessageW(window,WM_COMMAND,18,0); SendMessageW(window,WM_COMMAND,18,0);
require(c.marks.size()==1 && !c.marks.back().freehand && c.marks.back().points.size()==improved.size(),"redo restores improvement");
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
mouse(WM_LBUTTONDOWN,{180,120});
for(int i=1;i<=120;++i) {
double a=i*2*3.141592653589793/120;
mouse(WM_MOUSEMOVE,{LONG(std::lround(130+50*std::cos(a))),LONG(std::lround(120+50*std::sin(a)))});
}
mouse(WM_LBUTTONUP,c.stroke.to);
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Circle &&
!c.marks[0].freehand && !c.marks[0].isHighlight(),"recognized circle is a shape, not a pencil or highlight");
auto circle=c.marks[0];
require(ks::hitScreenshotMark(circle,circle.to,6)==ks::MarkPart::End,"recognized shape exposes bounding-box resize handle");
mouse(WM_LBUTTONDOWN,circle.to);
mouse(WM_MOUSEMOVE,{circle.to.x+30,circle.to.y+20});
mouse(WM_LBUTTONUP,{circle.to.x+30,circle.to.y+20});
require(c.marks[0].to.x>circle.to.x && c.marks[0].to.x-c.marks[0].from.x==c.marks[0].to.y-c.marks[0].from.y &&
c.marks[0].points[0].x>circle.points[0].x,"dragging recognized circle handle scales the whole circle proportionally");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].shape==ks::ScreenshotShape::Circle && c.marks[0].to.x==circle.to.x,"undo restores shape size");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].freehand && c.marks[0].shape==ks::ScreenshotShape::None,"undo recognition restores editable pencil stroke");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks[0].shape==ks::ScreenshotShape::Circle,"redo restores shape type");
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle}) {
auto shape=circle; shape.shape=kind;
auto resized=ks::dragScreenshotMark(shape,ks::MarkPart::End,shape.to,{shape.to.x+50,shape.to.y+20},320,240);
require(resized.points[0].x!=shape.points[0].x && resized.points[24].y!=shape.points[24].y,
"shape resize transforms all outline points in both axes");
auto moved=ks::cloneScreenshotMark(resized,{resized},320,240);
require(moved.shape==kind && !moved.isHighlight(),"clone preserves recognized shape type");
}
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
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{};
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"pencil cursor supports standard and high DPI");
require(info.xHotspot<UINT(size/3) && info.yHotspot>UINT(size*2/3),"pencil cursor draws from its tip");
if(info.hbmMask) DeleteObject(info.hbmMask);
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor);
}
ks::Surface cursorPreview(784,448);
PatBlt(cursorPreview.dc,0,0,784,448,WHITENESS);
int row=0;
for(int size:{32,48,64,96}) {
int column=0;
for(int tool:ks::CursorTools) {
auto cursor=ks::makeToolCursor(tool,size); ICONINFO info{};
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"tool cursors support standard and high DPI");
require(info.xHotspot==UINT(MulDiv(7,size,32)) && info.yHotspot==info.xHotspot,"tool cursor hotspot stays on crosshair center");
BITMAP mask{}; GetObjectW(info.hbmMask,sizeof(mask),&mask);
require(mask.bmWidth==size && mask.bmHeight==size*2,"cursor transparency mask matches requested DPI");
if(column%2) {
RECT tile{column*112,row*112,(column+1)*112,(row+1)*112};
auto dark=CreateSolidBrush(RGB(40,40,40)); FillRect(cursorPreview.dc,&tile,dark); DeleteObject(dark);
}
DrawIconEx(cursorPreview.dc,column*112,row*112,cursor,size,size,0,nullptr,DI_NORMAL);
if(info.hbmMask) DeleteObject(info.hbmMask);
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor); ++column;
}
++row;
}
auto dib=ks::screenshotClip(cursorPreview.dc,784,448,{}).formats[0].data;
BITMAPFILEHEADER bmp{}; bmp.bfType=0x4d42; bmp.bfOffBits=sizeof(bmp)+sizeof(BITMAPINFOHEADER); bmp.bfSize=DWORD(sizeof(bmp)+dib.size());
ks::Bytes preview(sizeof(bmp)); memcpy(preview.data(),&bmp,sizeof(bmp)); preview.insert(preview.end(),dib.begin(),dib.end());
ks::atomicWrite(L"build/cursor-preview.bmp",preview);
for(int tool:{27,26,21,10,11,22,23,24,25,19}) {
SendMessageW(window,WM_COMMAND,tool,0);
HCURSOR expected=nullptr;
if(tool==27) expected=LoadCursorW(nullptr,IDC_ARROW);
else if(tool==26) expected=LoadCursorW(nullptr,IDC_IBEAM);
else if(tool==21) expected=c.pencilCursor;
else for(size_t i=0;i<std::size(ks::CursorTools);++i) if(ks::CursorTools[i]==tool) expected=c.drawingCursors[i];
require(expected && ks::actionCursor(c,{-1,ks::MarkPart::None},true)==expected,"each tool uses its own drawing cursor");
require(ks::actionCursor(c,{-1,ks::MarkPart::None},false)==LoadCursorW(nullptr,IDC_ARROW),"outside canvas uses normal pointer");
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");
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
}
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c);
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"default tool hint is shown");
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"button leave restores contextual hint");
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
ks::beginInlineText(window,c,text,-1);
require(c.inlineText!=nullptr,"inline text control opens");
ks::updateStatus(c);
require(c.statusText.find(L"Ctrl+Enter")!=std::wstring::npos,"inline editing hint shows commit and cancel keys");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"First line\r\nДругий рядок");
require(c.textDraft.text==L"First line\r\nДругий рядок","inline text changes update draft");
SendMessageW(window,ks::FinishTextMessage,1,(LPARAM)c.inlineText);
require(!c.inlineText && c.marks.size()==1 && c.marks[0].text==text.text+L"First line\r\nДругий рядок","commit adds multiline text");
ks::beginInlineText(window,c,c.marks[0],0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Cancelled");
SendMessageW(window,ks::FinishTextMessage,2,(LPARAM)c.inlineText);
require(c.marks[0].text!=L"Cancelled","Escape cancels changes");
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
require(c.inlineText!=nullptr,"F2 edits inline");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited"); ks::finishInlineText(window,c,true);
require(c.marks[0].text==L"Edited","existing text edit commits");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].text!=L"Edited","undo restores original text");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks[0].text==L"Edited","redo restores text edit");
ks::beginInlineText(window,c,c.marks[0],0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L""); ks::finishInlineText(window,c,true);
require(c.marks.empty(),"committing empty text deletes object");
c.marks.push_back({{30,150},{260,150},0}); c.selected=0;
SendMessageW(window,WM_COMMAND,35,0);
require(c.inlineText && c.editingLabel,"label button opens inline editor");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Arrow caption");
ks::finishInlineText(window,c,true);
require(c.marks.size()==1 && c.marks[0].label==L"Arrow caption" && c.marks[0].isLine(),"label attaches to arrow");
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
require(c.inlineText && c.editingLabel,"F2 edits arrow caption");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L""); ks::finishInlineText(window,c,true);
require(c.marks.size()==1 && c.marks[0].label.empty(),"empty label removes caption without deleting arrow");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].label==L"Arrow caption","undo restores arrow caption");
auto caption=ks::screenshotArrowLabel(c.marks[0]);
auto captionGrip=ks::screenshotTextRotationHandle(caption,double(c.unit*0.6)*c.image->width/(c.view.right-c.view.left));
c.selected=0;
require(ks::markAt(c,captionGrip).second==ks::MarkPart::Rotation,"caption rotation handle is interactive");
c.marks[0]=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,captionGrip,{captionGrip.x+40,captionGrip.y+20},320,240,true);
auto angle=c.marks[0].rotation;
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Rotated caption"); ks::finishInlineText(window,c,true);
require(c.marks[0].rotation==angle && c.marks[0].label==L"Rotated caption","editing caption preserves its rotation");
auto rotating=text; rotating.text=L"Rotate"; c.marks.push_back(rotating); c.selected=1;
auto handle=ks::screenshotTextRotationHandle(rotating,double(c.unit*0.6)*c.image->width/(c.view.right-c.view.left));
require(ks::markAt(c,handle).second==ks::MarkPart::Rotation,"rotation handle is interactive");
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");
}
{
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));
};
SendMessageW(window,WM_COMMAND,27,0); c.marks.clear();
mouse(WM_LBUTTONDOWN,{40,160}); mouse(WM_MOUSEMOVE,{100,190}); mouse(WM_LBUTTONUP,{100,190});
require(c.marks.empty() && !c.dragging && c.selected==-1,"pointer drag on empty canvas never adds an object");
for(int tool : {11,22}) for(int target : {0,1,2}) {
if(target==0) c.marks={{{55,160},{55,160},1},{{255,160},{255,160},2}};
else {
c.marks={{{30,140},{80,190},0},{{230,140},{280,190},0}};
if(target==1) for(auto &mark:c.marks) mark.shape=ks::ScreenshotShape::Rectangle;
else for(auto &mark:c.marks) mark.from.y=mark.to.y=160;
}
SendMessageW(window,WM_COMMAND,tool,0);
BYTE savedKeys[256]{},drawingKeys[256]{};
require(GetKeyboardState(savedKeys)!=FALSE,"read thread keyboard state");
std::copy(savedKeys,savedKeys+256,drawingKeys); drawingKeys[VK_CONTROL]=0x80;
require(SetKeyboardState(drawingKeys)!=FALSE,"set thread-local Ctrl for drawing test");
mouse(WM_LBUTTONDOWN,{55,160}); mouse(WM_MOUSEMOVE,{255,160}); mouse(WM_LBUTTONUP,{255,160});
SetKeyboardState(savedKeys);
require(c.marks.size()==3,"drawing over existing objects creates a connector with Ctrl");
auto &connector=c.marks[2];
require(target==0?(connector.fromNumber==1 && connector.toNumber==2):
(connector.fromLink.target==c.marks[0].id && connector.toLink.target==c.marks[1].id),
"new arrow or line automatically attaches both endpoints to targets");
require(connector.ends==(tool==11?c.arrowEnds:ks::LineEnds::None),"arrow and straight-line tools retain their end styles");
auto before=connector.from;
SendMessageW(window,WM_KEYDOWN,'V',0);
require(c.pointer && !c.arrow && !c.freehand,"V selects pointer mode");
POINT body=target==0?POINT{55,160}:target==1?POINT{30,175}:POINT{30,160};
mouse(WM_LBUTTONDOWN,body); mouse(WM_MOUSEMOVE,{body.x,body.y-20}); mouse(WM_LBUTTONUP,{body.x,body.y-20});
require(c.selected==0 && c.marks.size()==3 && c.marks[2].from.y<before.y,
"pointer moves the target and the new connector follows without adding objects");
mouse(WM_LBUTTONDOWN,{150,220}); mouse(WM_LBUTTONUP,{150,220});
require(c.selected==-1 && c.marks.size()==3,"pointer click on empty space only clears selection");
}
}
for(bool manualAngle : {false,true}) {
c.marks={{{40,140},{180,60},0}}; c.selected=0;
c.marks[0].fontSize=18; c.marks[0].fontWeight=FW_BOLD;
c.marks[0].rotation=31; c.marks[0].labelRotationManual=manualAngle;
SendMessageW(window,WM_COMMAND,35,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Sloped caption");
ks::finishInlineText(window,c,true);
auto captionAngle=ks::screenshotArrowLabel(c.marks[0]).rotation;
require(captionAngle!=0,"caption retains its automatic or manual angle");
SendMessageW(window,WM_COMMAND,26,0);
LONG x=c.view.left+MulDiv(220,c.view.right-c.view.left,c.image->width);
LONG y=c.view.top+MulDiv(190,c.view.bottom-c.view.top,c.image->height);
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(x,y));
require(c.inlineText && !c.editingLabel,"standalone text starts independently of caption");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Independent text");
ks::finishInlineText(window,c,true);
require(c.marks.size()==2 && c.marks[1].isText() && c.marks[1].rotation==0,
"new standalone text does not inherit caption rotation");
require(c.marks[1].fontSize==18 && c.marks[1].fontWeight==FW_BOLD,
"new text still inherits font styling");
require(ks::screenshotArrowLabel(c.marks[0]).rotation==captionAngle,"new text does not change caption angle");
c.marks[1].rotation=22; c.selected=1;
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited standalone text");
ks::finishInlineText(window,c,true);
require(c.marks[1].rotation==22,"editing existing rotated text preserves its own angle");
SendMessageW(window,WM_COMMAND,27,0);
}
std::vector<ks::Mark> nudgeMarks;
nudgeMarks.push_back({{60,60},{60,60},1});
nudgeMarks.push_back({{60,60},{120,100},0});
auto nudgeText=text; nudgeText.text=L"Move"; nudgeText.from=nudgeText.to={60,60}; nudgeMarks.push_back(nudgeText);
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
ks::Mark mark{{60,60},{100,100},0}; mark.shape=shape; nudgeMarks.push_back(mark);
}
for(bool freehand:{false,true}) {
ks::Mark mark{{60,60},{120,100},0}; mark.points={{60,60},{90,80},{120,100}}; mark.freehand=freehand;
nudgeMarks.push_back(mark);
}
for(auto mark:nudgeMarks) {
c.marks={mark}; c.selected=0; c.selectedLabel=false;
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0); SendMessageW(window,WM_KEYDOWN,VK_DOWN,0);
require(c.marks[0].from.x==61 && c.marks[0].from.y==61,"every object nudges one pixel per key");
if(!mark.points.empty()) require(c.marks[0].points[1].x==91 && c.marks[0].points[1].y==81,"nudge moves entire stroke");
SendMessageW(window,WM_KEYDOWN,VK_LEFT,0); SendMessageW(window,WM_KEYDOWN,VK_UP,0);
require(c.marks[0].from.x==60 && c.marks[0].from.y==60,"opposite keys restore original position");
}
ks::Mark nudgeArrow{{60,60},{140,140},0}; nudgeArrow.label=L"Caption"; nudgeArrow.fontSize=12;
c.marks={nudgeArrow}; c.selected=0; c.selectedLabel=true;
auto beforeNudge=ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0]));
for(int i=0;i<5;++i) SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
auto afterNudge=ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0]));
require(afterNudge.left==beforeNudge.left+5 && afterNudge.top==beforeNudge.top,
"caption nudges exact screen pixels at a diagonal angle");
require(c.marks[0].from.x==60 && !c.marks[0].labelRotationManual,"caption nudge leaves arrow and automatic angle intact");
SendMessageW(window,WM_COMMAND,12,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+4,"undo reverses one nudge");
SendMessageW(window,WM_COMMAND,18,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
ks::copy(window,c,[](HWND,const ks::Clip &clip)->DWORD {
if(clip.formats.empty()) throw std::runtime_error("copy produced no image");
return 0;
});
require(IsWindow(window) && c.copied,"copy publishes screenshot and keeps editor open");
c.selected=0; c.selectedLabel=false;
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
require(!c.copied,"editing after copy requires confirmation again");
c.copied=true; SendMessageW(window,WM_COMMAND,12,0);
require(!c.copied,"undo after copy requires confirmation again");
c.copied=true; SendMessageW(window,WM_COMMAND,18,0);
require(!c.copied,"redo after copy requires confirmation again");
SetPropW(window,L"ConfirmationCentered",nullptr);
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
wchar_t className[64]{}; GetClassNameW(popup,className,64);
if(GetWindow(popup,GW_OWNER)==(HWND)parent && std::wstring(className)==L"#32770") {
RECT ownerRect{},popupRect{};
GetWindowRect((HWND)parent,&ownerRect); GetWindowRect(popup,&popupRect);
if(std::abs(ownerRect.left+ownerRect.right-popupRect.left-popupRect.right)<=4 &&
std::abs(ownerRect.top+ownerRect.bottom-popupRect.top-popupRect.bottom)<=4)
SetPropW((HWND)parent,L"ConfirmationCentered",(HANDLE)1);
PostMessageW(popup,TDM_CLICK_BUTTON,IDNO,0);
}
return TRUE;
},(LPARAM)owner);
KillTimer(owner,id);
});
SendMessageW(window,WM_CLOSE,0,0);
require(IsWindow(window),"declining close confirmation keeps the screenshot open");
require(GetPropW(window,L"ConfirmationCentered")!=nullptr,"confirmation is centered on editor instead of screen");
RemovePropW(window,L"ConfirmationCentered");
c.copied=true;
SendMessageW(window,WM_KEYDOWN,VK_ESCAPE,0);
require(IsWindow(window),"Escape does not close the screenshot editor");
SendMessageW(window,WM_CLOSE,0,0);
require(!IsWindow(window),"copied screenshot closes without confirmation");
static bool published=false;
static bool notificationAccepted=false;
static HWND notificationWindow{};
static bool expectHidden=false;
app.notify=[](const ks::Clip &) {
if(!published) throw std::runtime_error("notification preceded clipboard publication");
if(expectHidden && IsWindowVisible(notificationWindow))
throw std::runtime_error("selection overlay is still visible when notification is sent");
return notificationAccepted;
};
for(bool autoOpen : {true,false}) for (bool accepted : {true, false}) {
app.autoOpenEditor=autoOpen;
published=false;
notificationAccepted=accepted;
ks::Capture selection(app);
selection.image=std::make_unique<ks::Surface>(100,100);
PatBlt(selection.image->dc,0,0,100,100,WHITENESS);
selection.start={10,20}; selection.end={70,60};
auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection);
notificationWindow=overlay; expectHidden=!autoOpen;
ShowWindow(overlay,SW_SHOWNOACTIVATE);
ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD {
auto pixels=ks::decodeImage(clip);
if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection");
published=true; return 0;
});
if(!autoOpen && accepted) {
require(published && !IsWindow(overlay),"notification-first closes selection after successful submission");
continue;
}
require(published && selection.copied && IsWindowVisible(overlay) && selection.editing,
"selection opens editor whether notification is accepted or rejected");
require(selection.image->width==60 && selection.image->height==40,
"automatically opened editor contains the selected region");
SendMessageW(overlay,WM_CLOSE,0,0);
require(!IsWindow(overlay),"automatically copied screenshot closes without confirmation");
}
DestroyWindow(app.window); app.window=nullptr;
CoUninitialize();
std::cout<<"Passed screenshot editor UI integration checks\n";
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}