Release v0.4.0: add screen capture and annotation editor
This commit is contained in:
+406
@@ -1,7 +1,9 @@
|
||||
#include "app.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
#include <commctrl.h>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
@@ -29,6 +31,410 @@ int main(int argc, char **argv) {
|
||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||
require(app.window!=nullptr,"message-only test window");
|
||||
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
||||
for(int size:{22,44,55}) {
|
||||
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
||||
require(handle!=nullptr,"Lucide toolbar icon loads at standard and high DPI");
|
||||
DestroyIcon(handle);
|
||||
}
|
||||
{
|
||||
ks::ScreenshotHistory history;
|
||||
std::vector<ks::ScreenshotMark> marks;
|
||||
require(!history.undo(marks) && !history.redo(marks),"empty annotation history is safe");
|
||||
history.remember(marks);
|
||||
marks.push_back({{20,20},{20,20},1,24});
|
||||
history.remember(marks);
|
||||
marks.push_back({{44,20},{100,20},0,15,8,1,0});
|
||||
require(history.undo(marks) && marks.size()==1 && history.canRedo(),"undo preserves state for redo");
|
||||
require(history.undo(marks) && marks.empty(),"multiple undo steps restore empty canvas");
|
||||
require(history.redo(marks) && marks[0].radius==24,"redo restores marker size");
|
||||
require(history.redo(marks) && marks.size()==2 && marks[1].thickness==8 && marks[1].fromNumber==1,
|
||||
"redo restores arrow style and endpoint links");
|
||||
require(!history.canRedo(),"redo becomes unavailable after last replay");
|
||||
history.undo(marks);
|
||||
history.remember(marks); marks[0].radius=40;
|
||||
require(!history.canRedo() && !history.redo(marks),"new edit discards redo branch");
|
||||
require(history.undo(marks) && marks[0].radius==24,"new branch remains undoable");
|
||||
ks::ScreenshotHistory bounded;
|
||||
for(int i=0;i<110;++i) { bounded.remember(marks); marks[0].radius=i; }
|
||||
int count=0; while(bounded.undo(marks)) ++count;
|
||||
require(count==100,"annotation undo history stays bounded");
|
||||
count=0; while(bounded.redo(marks)) ++count;
|
||||
require(count==100 && marks[0].radius==109,"bounded history can replay all retained edits");
|
||||
}
|
||||
{
|
||||
HDC dc=CreateCompatibleDC(nullptr);
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader={sizeof(BITMAPINFOHEADER),160,-100,1,32,BI_RGB};
|
||||
void *pixels{};
|
||||
auto bitmap=CreateDIBSection(dc,&info,DIB_RGB_COLORS,&pixels,nullptr,0);
|
||||
auto old=SelectObject(dc,bitmap);
|
||||
PatBlt(dc,0,0,160,100,WHITENESS);
|
||||
auto plain=ks::screenshotClip(dc,160,100,{});
|
||||
auto marked=ks::screenshotClip(dc,160,100,{{{30,30},{30,30},1},{{60,70},{130,70},0}});
|
||||
auto &bytes=marked.formats[0].data;
|
||||
BITMAPINFOHEADER header{}; memcpy(&header,bytes.data(),sizeof(header));
|
||||
require(header.biWidth==160&&header.biHeight==-100,"capture preserves source resolution and orientation");
|
||||
require(bytes.size()==sizeof(header)+160*100*4,"capture exports complete 32-bit DIB");
|
||||
auto pixel=[&](int x,int y) { auto offset=sizeof(header)+(y*160+x)*4; return RGB(bytes[offset+2],bytes[offset+1],bytes[offset]); };
|
||||
require(pixel(30,20)==RGB(225,42,50),"number badge renders in exported image");
|
||||
require(pixel(90,70)==RGB(225,42,50),"arrow shaft renders in exported image");
|
||||
require(pixel(5,5)==RGB(255,255,255),"unmarked pixels preserved");
|
||||
ks::ScreenshotMark blueArrow{{20,50},{140,50},0}; blueArrow.color=RGB(0,80,220);
|
||||
ks::ScreenshotMark greenBadge{{30,30},{30,30},1}; greenBadge.color=RGB(0,200,80);
|
||||
auto colored=ks::screenshotClip(dc,160,100,{blueArrow,greenBadge});
|
||||
auto colorAt=[&](int x,int y) {
|
||||
auto &b=colored.formats[0].data; auto at=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
|
||||
return RGB(b[at+2],b[at+1],b[at]);
|
||||
};
|
||||
require(colorAt(70,50)==blueArrow.color && colorAt(132,50)==blueArrow.color,
|
||||
"arrow shaft and head use object color");
|
||||
require(colorAt(30,20)==greenBadge.color,"badge fill uses object color");
|
||||
ks::ScreenshotHistory recolors;
|
||||
std::vector<ks::ScreenshotMark> coloredMarks{blueArrow,greenBadge};
|
||||
recolors.remember(coloredMarks); coloredMarks[0].color=RGB(200,0,100);
|
||||
recolors.remember(coloredMarks); coloredMarks[1].color=RGB(255,255,0);
|
||||
require(recolors.undo(coloredMarks) && coloredMarks[1].color==greenBadge.color,"undo restores badge color");
|
||||
require(recolors.undo(coloredMarks) && coloredMarks[0].color==blueArrow.color,"undo restores arrow color");
|
||||
require(recolors.redo(coloredMarks) && coloredMarks[0].color==RGB(200,0,100),"redo restores arrow recoloring");
|
||||
require(ks::cloneScreenshotMark(greenBadge,{greenBadge},160,100).color==greenBadge.color,
|
||||
"cloned badge retains customized color");
|
||||
ks::ScreenshotMark thickArrow{{20,50},{140,50},0,15,12};
|
||||
auto grip=ks::screenshotThicknessHandle(thickArrow);
|
||||
require(grip.x==80 && grip.y==72,"thickness grip sits beside arrow midpoint");
|
||||
auto thicker=ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,grip.y+4},160,100);
|
||||
require(thicker.thickness==20 && thicker.from.x==20 && thicker.to.x==140,
|
||||
"dragging thickness grip changes width without changing endpoints");
|
||||
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x+20,grip.y},160,100).thickness==12,
|
||||
"movement along arrow does not change thickness");
|
||||
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,0},160,100).thickness==1,
|
||||
"thickness grip respects minimum width");
|
||||
auto cloned=ks::cloneScreenshotMark(thickArrow,{},160,100);
|
||||
require(cloned.thickness==12 && cloned.from.x!=thickArrow.from.x && cloned.to.x-cloned.from.x==120,
|
||||
"cloned arrow is offset and preserves dimensions and style");
|
||||
auto thick=ks::screenshotClip(dc,160,100,{thickArrow});
|
||||
auto shaftEdge=sizeof(BITMAPINFOHEADER)+(54*160+40)*4;
|
||||
require(thick.formats[0].data[shaftEdge+2]==225 && thick.formats[0].data[shaftEdge+1]==42,
|
||||
"export renders edited arrow thickness");
|
||||
thickArrow.thickness=1;
|
||||
auto thin=ks::screenshotClip(dc,160,100,{thickArrow});
|
||||
require(thin.formats[0].data[shaftEdge+1]==255,"thin arrow leaves surrounding pixels untouched");
|
||||
thickArrow.thickness=12;
|
||||
require(ks::hitScreenshotMark(thickArrow,{60,55},1)==ks::MarkPart::Body,
|
||||
"thick arrow can be selected along its visible edge");
|
||||
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::End,{140,50},{145,70},160,100).thickness==12,
|
||||
"editing arrow geometry preserves thickness");
|
||||
ks::ScreenshotMark badge{{60,50},{60,50},2};
|
||||
auto clonedBadge=ks::cloneScreenshotMark(badge,{badge,{{90,20},{90,20},5}},160,100);
|
||||
require(clonedBadge.number==1 && clonedBadge.radius==15 && clonedBadge.from.x==76,
|
||||
"cloned badge fills first numbering gap and keeps diameter");
|
||||
require(ks::nextScreenshotNumber({})==1,"empty capture starts numbering at one");
|
||||
std::vector<ks::ScreenshotMark> numbered{
|
||||
{{10,10},{10,10},4},{{20,10},{20,10},1},{{30,10},{30,10},3},{{0,0},{100,0},0}};
|
||||
require(ks::nextScreenshotNumber(numbered)==2,"new badge fills interior gap regardless of stacking order");
|
||||
numbered.push_back({{40,10},{40,10},2});
|
||||
require(ks::nextScreenshotNumber(numbered)==5,"full sequence advances past existing numbers");
|
||||
numbered.erase(numbered.begin()+1);
|
||||
require(ks::nextScreenshotNumber(numbered)==1,"deleted first number is reused");
|
||||
require(numbered[0].number==4 && numbered[1].number==3,"number allocation never renumbers existing objects");
|
||||
std::vector<ks::ScreenshotMark> linked{
|
||||
{{30,50},{30,50},1}, {{130,50},{130,50},2}, {{30,50},{130,50},0}};
|
||||
ks::linkScreenshotEndpoint(linked[2],true,{30,50},linked,6);
|
||||
ks::linkScreenshotEndpoint(linked[2],false,{130,50},linked,6);
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[2].fromNumber==1 && linked[2].toNumber==2 && linked[2].from.x==45 && linked[2].to.x==115,
|
||||
"arrow endpoints attach to badge rims");
|
||||
auto clonedLinked=ks::cloneScreenshotMark(linked[2],linked,160,100);
|
||||
require(clonedLinked.fromNumber==0 && clonedLinked.toNumber==0,"cloned arrow does not retain original links");
|
||||
auto widthLinked=ks::dragScreenshotMark(linked[2],ks::MarkPart::Thickness,{80,70},{80,74},160,100);
|
||||
require(widthLinked.fromNumber==1 && widthLinked.toNumber==2,"thickness handle preserves endpoint links");
|
||||
linked[0].from.y=20; linked[0].to.y=20;
|
||||
linked[1].radius=25;
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[2].from.y>20 && linked[2].from.y<50 && linked[2].to.x<110,
|
||||
"linked arrows follow badge movement and resize");
|
||||
auto detached=ks::dragScreenshotMark(linked[2],ks::MarkPart::Start,linked[2].from,{10,90},160,100);
|
||||
require(detached.fromNumber==0 && detached.toNumber==2,"endpoint drag detaches only that endpoint");
|
||||
ks::linkScreenshotEndpoint(detached,true,{10,90},linked,6);
|
||||
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");
|
||||
auto lastPoint=linked[2].from;
|
||||
linked.erase(linked.begin());
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[1].fromNumber==0 && linked[1].toNumber==2 && linked[1].from.x==lastPoint.x,
|
||||
"deleting badge detaches without moving orphaned endpoint");
|
||||
linked.push_back({{10,10},{10,10},1});
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[1].fromNumber==0,"reused number cannot reconnect a deleted link");
|
||||
require(ks::hitScreenshotMark(badge,{60,50},20)==ks::MarkPart::Body,"badge center moves even with large hit tolerance");
|
||||
require(ks::hitScreenshotMark(badge,{75,50},6)==ks::MarkPart::Radius,"badge rim exposes resize handle");
|
||||
auto enlarged=ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{95,50},160,100);
|
||||
require(enlarged.radius==35 && enlarged.number==2,"badge resizes without changing its number");
|
||||
auto large=ks::screenshotClip(dc,160,100,{enlarged});
|
||||
const auto &largeBytes=large.formats[0].data;
|
||||
auto red=sizeof(BITMAPINFOHEADER)+(50*160+90)*4;
|
||||
require(largeBytes[red+2]==225 && largeBytes[red+1]==42,"export uses edited badge diameter");
|
||||
require(ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{60,50},160,100).radius==8,
|
||||
"badge diameter has a usable minimum");
|
||||
ks::ScreenshotMark arrow{{20,20},{100,60},0};
|
||||
require(ks::hitScreenshotMark(arrow,{60,40},5)==ks::MarkPart::Body,"arrow shaft selects whole arrow");
|
||||
require(ks::hitScreenshotMark(arrow,{100,60},5)==ks::MarkPart::End,"arrow tip selects endpoint");
|
||||
require(ks::hitScreenshotMark(arrow,{60,80},5)==ks::MarkPart::None,"empty canvas misses arrow");
|
||||
auto moved=ks::dragScreenshotMark(arrow,ks::MarkPart::Body,{60,40},{150,90},160,100);
|
||||
require(moved.to.x==159 && moved.to.y==99 && moved.to.x-moved.from.x==80 && moved.to.y-moved.from.y==40,
|
||||
"moving arrow preserves shape at image boundary");
|
||||
auto stretched=ks::dragScreenshotMark(arrow,ks::MarkPart::End,{100,60},{140,80},160,100);
|
||||
require(stretched.from.x==20 && stretched.from.y==20 && stretched.to.x==140 && stretched.to.y==80,
|
||||
"endpoint drag leaves opposite endpoint fixed");
|
||||
require(ks::screenshotClip(dc,160,100,{}).formats[0].data==plain.formats[0].data,
|
||||
"annotations do not modify original capture");
|
||||
ks::ScreenshotMark highlight{{20,50},{120,50},0,15,24};
|
||||
highlight.points={{20,50},{120,50}}; highlight.color=RGB(255,255,0);
|
||||
SetPixel(dc,60,50,RGB(0,0,0));
|
||||
auto yellow=ks::screenshotClip(dc,160,100,{highlight});
|
||||
auto readPixel=[](const ks::Clip &clip,int x,int y) {
|
||||
auto &b=clip.formats[0].data; auto offset=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
|
||||
return RGB(b[offset+2],b[offset+1],b[offset]);
|
||||
};
|
||||
require(readPixel(yellow,40,50)==RGB(255,255,153),"highlight is translucent in exported image");
|
||||
require(readPixel(yellow,60,50)==RGB(0,0,0),"highlight keeps black text legible");
|
||||
require(readPixel(yellow,40,10)==RGB(255,255,255),"highlight leaves pixels outside brush unchanged");
|
||||
highlight.points.push_back({20,50});
|
||||
require(readPixel(ks::screenshotClip(dc,160,100,{highlight}),40,50)==RGB(255,255,153),
|
||||
"overlapping a stroke with itself does not accumulate opacity");
|
||||
ks::ScreenshotHistory colorHistory;
|
||||
std::vector<ks::ScreenshotMark> strokes{highlight};
|
||||
colorHistory.remember(strokes); strokes[0].color=RGB(0,255,255);
|
||||
require(readPixel(ks::screenshotClip(dc,160,100,strokes),40,50)==RGB(153,255,255),
|
||||
"changing highlight color rerenders original stroke");
|
||||
require(colorHistory.undo(strokes) && strokes[0].color==RGB(255,255,0),"undo restores highlight color");
|
||||
require(colorHistory.redo(strokes) && strokes[0].color==RGB(0,255,255),"redo restores highlight recolor");
|
||||
require(ks::hitScreenshotMark(highlight,{80,55},1)==ks::MarkPart::Body,"highlight stroke is selectable");
|
||||
require(ks::hitScreenshotMark(highlight,{80,10},1)==ks::MarkPart::None,"highlight hit test excludes empty area");
|
||||
auto movedHighlight=ks::dragScreenshotMark(highlight,ks::MarkPart::Body,{20,50},{30,60},160,100);
|
||||
require(movedHighlight.points[1].x==130 && movedHighlight.points[1].y==60,"moving highlight translates entire stroke");
|
||||
auto cloneHighlight=ks::cloneScreenshotMark(highlight,{highlight},160,100);
|
||||
require(cloneHighlight.points[0].x!=highlight.points[0].x && cloneHighlight.color==highlight.color &&
|
||||
cloneHighlight.thickness==24 && cloneHighlight.number==0,"cloning highlight preserves brush and color");
|
||||
PatBlt(dc,0,0,160,100,WHITENESS);
|
||||
ks::ScreenshotMark line{{20,50},{140,50},0}; line.ends=ks::LineEnds::None;
|
||||
ks::ScreenshotMark textMark{{5,5},{5,5},0}; textMark.text=L"Hello &\r\nСвіт";
|
||||
textMark.fontSize=14; textMark.color=RGB(0,80,180);
|
||||
auto textBounds=ks::screenshotTextBounds(textMark);
|
||||
require(textBounds.right>30 && textBounds.bottom>30,"multiline Unicode text is measured");
|
||||
auto textImage=ks::screenshotClip(dc,160,100,{textMark});
|
||||
int textInk=0;
|
||||
for(int y=5;y<textBounds.bottom;++y) for(int x=5;x<textBounds.right;++x)
|
||||
if(readPixel(textImage,x,y)==textMark.color) ++textInk;
|
||||
require(textInk>20,"text exports visible colored glyphs");
|
||||
require(ks::hitScreenshotMark(textMark,{6,6},2)==ks::MarkPart::Body,"text is movable inside its bounds");
|
||||
require(ks::hitScreenshotMark(textMark,{150,90},2)==ks::MarkPart::None,"text excludes empty canvas");
|
||||
auto bigText=textMark; bigText.fontSize=28;
|
||||
auto bigBounds=ks::screenshotTextBounds(bigText);
|
||||
require(bigBounds.bottom>textBounds.bottom && bigBounds.right>textBounds.right,"font size scales text bounds");
|
||||
auto serifText=textMark; serifText.fontName=L"Courier New"; serifText.fontWeight=FW_BOLD; serifText.fontItalic=true;
|
||||
require(ks::screenshotClip(dc,160,100,{serifText}).formats[0].data!=textImage.formats[0].data,
|
||||
"font family and style affect exported glyphs");
|
||||
auto movedText=ks::dragScreenshotMark(textMark,ks::MarkPart::Body,{6,6},{16,16},160,100);
|
||||
auto rotatedText=textMark; rotatedText.rotation=90;
|
||||
auto rotatedBounds=ks::screenshotTextBounds(rotatedText);
|
||||
require(std::abs((rotatedBounds.right-rotatedBounds.left)-(textBounds.bottom-textBounds.top))<=1,
|
||||
"rotation transforms text bounds");
|
||||
POINT center{(textBounds.left+textBounds.right)/2,(textBounds.top+textBounds.bottom)/2};
|
||||
require(ks::hitScreenshotMark(rotatedText,center,2)==ks::MarkPart::Body,"rotated text hit test follows text");
|
||||
auto rotationGrip=ks::screenshotTextRotationHandle(textMark);
|
||||
auto rotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x+40,center.y-25},160,100,true);
|
||||
require(std::abs(std::remainder(rotation.rotation,15))<0.001 && rotation.rotation!=0,"Shift snaps rotation to 15 degrees");
|
||||
auto reverseRotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x-40,center.y-25},160,100,false);
|
||||
require(reverseRotation.rotation<0,"text rotates in the opposite direction");
|
||||
require(ks::screenshotClip(dc,160,100,{rotatedText}).formats[0].data!=textImage.formats[0].data,
|
||||
"rotation is included in exported text pixels");
|
||||
require(ks::cloneScreenshotMark(rotatedText,{rotatedText},160,100).rotation==90,"cloning preserves text rotation");
|
||||
ks::ScreenshotMark labeled{{20,70},{140,70},0}; labeled.label=L"Label"; labeled.fontSize=12;
|
||||
auto label=ks::screenshotArrowLabel(labeled);
|
||||
auto labelBounds=ks::screenshotTextBounds(label);
|
||||
POINT labelCenter{(labelBounds.left+labelBounds.right)/2,(labelBounds.top+labelBounds.bottom)/2};
|
||||
auto labelGrip=ks::screenshotTextRotationHandle(label);
|
||||
auto diagonal=labeled; diagonal.to={100,150};
|
||||
for(int degrees:{0,45,90,135,180,-135,-90,-45}) {
|
||||
double angle=degrees*3.141592653589793/180;
|
||||
auto turning=labeled; turning.from={100,100};
|
||||
turning.to={100+LONG(std::lround(80*std::cos(angle))),100+LONG(std::lround(80*std::sin(angle)))};
|
||||
auto box=ks::screenshotTextBounds(ks::screenshotArrowLabel(turning));
|
||||
double x=(box.left+box.right)/2.0-(turning.from.x+turning.to.x)/2.0;
|
||||
double y=(box.top+box.bottom)/2.0-(turning.from.y+turning.to.y)/2.0;
|
||||
require(std::abs(-x*std::sin(angle)+y*std::cos(angle)+24)<1.5,
|
||||
"caption keeps its perpendicular distance and side as line rotates");
|
||||
require(std::abs(x*std::cos(angle)+y*std::sin(angle))<1.5,
|
||||
"caption remains centered along rotated line");
|
||||
}
|
||||
auto beforeDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(diagonal));
|
||||
auto offsetDiagonal=ks::dragScreenshotMark(diagonal,ks::MarkPart::Label,{0,0},{12,-8},200,200);
|
||||
require(!offsetDiagonal.labelRotationManual,"moving caption preserves automatic rotation");
|
||||
auto movedCaptionSlope=ks::dragScreenshotMark(offsetDiagonal,ks::MarkPart::End,offsetDiagonal.to,{20,150},200,200);
|
||||
require(std::abs(ks::screenshotArrowLabel(movedCaptionSlope).rotation-90)<0.001,
|
||||
"repositioned caption continues following line angle");
|
||||
auto afterDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(offsetDiagonal));
|
||||
require(std::abs(afterDrag.left-beforeDrag.left-12)<=1 && std::abs(afterDrag.top-beforeDrag.top+8)<=1,
|
||||
"dragging diagonal caption follows pointer in screen coordinates");
|
||||
require(std::abs(ks::screenshotArrowLabel(diagonal).rotation-45)<0.001,"caption defaults to line angle");
|
||||
auto changedSlope=ks::dragScreenshotMark(diagonal,ks::MarkPart::End,diagonal.to,{20,150},200,200);
|
||||
require(std::abs(ks::screenshotArrowLabel(changedSlope).rotation-90)<0.001,"automatic caption follows endpoint movement");
|
||||
auto reversed=diagonal; std::swap(reversed.from,reversed.to);
|
||||
require(std::abs(ks::screenshotArrowLabel(reversed).rotation-45)<0.001,"automatic caption remains upright on reversed line");
|
||||
auto diagonalLabel=ks::screenshotArrowLabel(diagonal);
|
||||
auto diagonalGrip=ks::screenshotTextRotationHandle(diagonalLabel);
|
||||
auto clickOnly=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,diagonalGrip,200,200,true);
|
||||
require(!clickOnly.labelRotationManual,"clicking rotation handle without dragging preserves automatic angle");
|
||||
auto manual=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,{diagonalGrip.x+12,diagonalGrip.y},200,200,false);
|
||||
require(manual.labelRotationManual && manual.rotation>45 && manual.rotation<90,
|
||||
"manual rotation starts at displayed automatic angle without jumping");
|
||||
auto manualResized=ks::dragScreenshotMark(manual,ks::MarkPart::End,manual.to,{20,150},200,200);
|
||||
require(ks::screenshotArrowLabel(manualResized).rotation==manual.rotation,"manual angle survives line resizing");
|
||||
require(ks::cloneScreenshotMark(manual,{manual},200,200).labelRotationManual,"clone preserves manual rotation mode");
|
||||
ks::ScreenshotHistory autoAngleHistory; std::vector<ks::ScreenshotMark> autoAngles{diagonal};
|
||||
autoAngleHistory.remember(autoAngles); autoAngles[0]=manual;
|
||||
require(autoAngleHistory.undo(autoAngles) && !autoAngles[0].labelRotationManual,"undo restores automatic angle mode");
|
||||
require(autoAngleHistory.redo(autoAngles) && autoAngles[0].labelRotationManual,"redo restores manual angle mode");
|
||||
auto curvedCaption=diagonal; curvedCaption.freehand=true;
|
||||
curvedCaption.points={{20,70},{60,70},{60,110},{100,150}};
|
||||
require(std::abs(ks::screenshotArrowLabel(curvedCaption).rotation-90)<0.001,"curve caption follows its middle segment");
|
||||
auto rotatedCaption=ks::dragScreenshotMark(labeled,ks::MarkPart::Rotation,labelGrip,
|
||||
{labelCenter.x+35,labelCenter.y-20},160,100,true);
|
||||
require(rotatedCaption.rotation!=0 && std::abs(std::remainder(rotatedCaption.rotation,15))<0.001,
|
||||
"arrow caption rotation snaps to 15 degrees");
|
||||
require(rotatedCaption.from.x==labeled.from.x && rotatedCaption.from.y==labeled.from.y &&
|
||||
rotatedCaption.to.x==labeled.to.x && rotatedCaption.to.y==labeled.to.y &&
|
||||
rotatedCaption.labelOffset.x==labeled.labelOffset.x && rotatedCaption.labelOffset.y==labeled.labelOffset.y,
|
||||
"rotating caption preserves arrow geometry and caption position");
|
||||
require(ks::screenshotArrowLabel(rotatedCaption).rotation==rotatedCaption.rotation,
|
||||
"linked caption uses the regular text rotation");
|
||||
require(ks::screenshotClip(dc,160,100,{rotatedCaption}).formats[0].data!=ks::screenshotClip(dc,160,100,{labeled}).formats[0].data,
|
||||
"caption rotation is exported");
|
||||
require(ks::cloneScreenshotMark(rotatedCaption,{rotatedCaption},160,100).rotation==rotatedCaption.rotation,
|
||||
"cloning retains caption angle");
|
||||
ks::ScreenshotHistory captionHistory; std::vector<ks::ScreenshotMark> captionMarks{labeled};
|
||||
captionHistory.remember(captionMarks); captionMarks[0]=rotatedCaption;
|
||||
require(captionHistory.undo(captionMarks) && captionMarks[0].rotation==0,"undo restores caption angle");
|
||||
require(captionHistory.redo(captionMarks) && captionMarks[0].rotation==rotatedCaption.rotation,"redo restores caption angle");
|
||||
require(ks::hitScreenshotMark(labeled,labelCenter,2)==ks::MarkPart::Label,"arrow label is independently draggable");
|
||||
auto repositioned=ks::dragScreenshotMark(labeled,ks::MarkPart::Label,labelCenter,{labelCenter.x+10,labelCenter.y-8},160,100);
|
||||
require(repositioned.labelOffset.x==10 && repositioned.labelOffset.y==-32 && repositioned.from.x==20,
|
||||
"label drag changes only its offset");
|
||||
auto movedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::Body,{20,70},{25,75},160,100);
|
||||
auto movedLabel=ks::screenshotArrowLabel(movedArrow);
|
||||
require(movedLabel.from.x==label.from.x+5 && movedLabel.from.y==label.from.y+5,"label follows arrow movement");
|
||||
auto resizedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::End,labeled.to,{120,70},160,100);
|
||||
require(ks::screenshotArrowLabel(resizedArrow).from.x==label.from.x-10,"label follows arrow resizing");
|
||||
auto noLabel=labeled; noLabel.label.clear();
|
||||
require(ks::screenshotClip(dc,160,100,{labeled}).formats[0].data!=ks::screenshotClip(dc,160,100,{noLabel}).formats[0].data,
|
||||
"arrow label is included in clipboard export");
|
||||
require(ks::cloneScreenshotMark(labeled,{labeled},160,100).label==labeled.label,"cloning retains arrow label");
|
||||
ks::ScreenshotHistory annotationHistory; std::vector<ks::ScreenshotMark> annotations{rotatedText,labeled};
|
||||
annotationHistory.remember(annotations); annotations[0]=rotation; annotations[1]=repositioned;
|
||||
require(annotationHistory.undo(annotations) && annotations[0].rotation==90 && annotations[1].labelOffset.x==0,
|
||||
"undo restores rotation and label position");
|
||||
require(annotationHistory.redo(annotations) && annotations[0].rotation==rotation.rotation && annotations[1].labelOffset.x==10,
|
||||
"redo restores rotation and label position");
|
||||
require(movedText.from.x==15 && movedText.from.y==15 && movedText.text==textMark.text,"text movement preserves content");
|
||||
auto oversizedText=textMark; oversizedText.fontSize=100;
|
||||
auto oversizedBounds=ks::screenshotTextBounds(oversizedText);
|
||||
require(oversizedBounds.right-oversizedBounds.left>160 && oversizedBounds.bottom-oversizedBounds.top>100,
|
||||
"large text exceeds both canvas dimensions");
|
||||
for(int direction:{-1,1}) {
|
||||
auto moved=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*20,direction*20},160,100);
|
||||
require(moved.from.x==oversizedText.from.x+direction*20 && moved.from.y==oversizedText.from.y+direction*20,
|
||||
"oversized text moves in either direction on both axes");
|
||||
auto limited=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*10000,direction*10000},160,100);
|
||||
auto bounds=ks::screenshotTextBounds(limited);
|
||||
require(bounds.right>=16 && bounds.left<=144 && bounds.bottom>=16 && bounds.top<=84,
|
||||
"oversized text remains selectable at canvas edges");
|
||||
}
|
||||
auto clonedText=ks::cloneScreenshotMark(serifText,{serifText},160,100);
|
||||
require(clonedText.text==serifText.text && clonedText.fontName==serifText.fontName &&
|
||||
clonedText.fontWeight==FW_BOLD && clonedText.fontItalic,"text cloning preserves font and content");
|
||||
ks::ScreenshotHistory textHistory; std::vector<ks::ScreenshotMark> texts{textMark};
|
||||
textHistory.remember(texts); texts[0]=serifText; texts[0].text=L"Changed";
|
||||
require(textHistory.undo(texts) && texts[0].text==textMark.text && texts[0].fontName==textMark.fontName,
|
||||
"undo restores text and font");
|
||||
require(textHistory.redo(texts) && texts[0].text==L"Changed" && texts[0].fontItalic,
|
||||
"redo restores text edits");
|
||||
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
|
||||
ks::ScreenshotMark figure{{30,20},{90,80},0}; figure.shape=shape;
|
||||
if(shape==ks::ScreenshotShape::Ellipse) figure.to.x=130;
|
||||
figure.color=RGB(0,140,80); figure.thickness=4;
|
||||
auto image=ks::screenshotClip(dc,160,100,{figure});
|
||||
int mid=(figure.from.x+figure.to.x)/2;
|
||||
require(readPixel(image,mid,20)==figure.color,"shape contour renders in chosen color");
|
||||
require(readPixel(image,mid,50)==RGB(255,255,255),"shape interior remains transparent");
|
||||
require(ks::hitScreenshotMark(figure,{mid,20},2)==ks::MarkPart::Body,"shape contour is movable");
|
||||
require(ks::hitScreenshotMark(figure,{mid,50},2)==ks::MarkPart::None,"shape interior does not intercept objects");
|
||||
auto resized=ks::dragScreenshotMark(figure,ks::MarkPart::End,figure.to,{100,85},160,100);
|
||||
require(resized.shape==shape && resized.to.x==100,"shape corner can resize object");
|
||||
if(shape==ks::ScreenshotShape::Circle)
|
||||
require(resized.to.x-resized.from.x==resized.to.y-resized.from.y,"circle remains round when resized");
|
||||
auto moved=ks::dragScreenshotMark(figure,ks::MarkPart::Body,{mid,20},{mid+5,25},160,100);
|
||||
require(moved.from.x==35 && moved.to.x==figure.to.x+5,"moving shape preserves dimensions");
|
||||
auto duplicate=ks::cloneScreenshotMark(figure,{figure},160,100);
|
||||
require(duplicate.shape==shape && duplicate.color==figure.color,"cloning preserves shape and color");
|
||||
ks::ScreenshotHistory history; std::vector<ks::ScreenshotMark> figures{figure};
|
||||
history.remember(figures); figures[0]=resized;
|
||||
require(history.undo(figures) && figures[0].to.x==figure.to.x,"shape resize can be undone");
|
||||
require(history.redo(figures) && figures[0].to.x==resized.to.x,"shape resize can be redone");
|
||||
}
|
||||
auto circleCorner=ks::constrainScreenshotShape(ks::ScreenshotShape::Circle,{60,60},{0,10},160,100);
|
||||
require(circleCorner.x==0 && circleCorner.y==0,"circle supports reverse drawing and canvas boundaries");
|
||||
auto solidLine=ks::screenshotClip(dc,160,100,{line});
|
||||
require(readPixel(solidLine,28,47)==RGB(255,255,255) && readPixel(solidLine,132,47)==RGB(255,255,255),
|
||||
"plain line has no arrowheads");
|
||||
for(auto ends:{ks::LineEnds::End,ks::LineEnds::Start,ks::LineEnds::Both}) {
|
||||
line.ends=ends;
|
||||
auto image=ks::screenshotClip(dc,160,100,{line});
|
||||
require((readPixel(image,28,47)==line.color)==(ends!=ks::LineEnds::End),"start arrowhead matches ends setting");
|
||||
require((readPixel(image,132,47)==line.color)==(ends!=ks::LineEnds::Start),"end arrowhead matches ends setting");
|
||||
}
|
||||
line.ends=ks::LineEnds::None;
|
||||
std::vector<ks::Bytes> styleImages;
|
||||
ks::ScreenshotMark shaky{{20,50},{140,50},0};
|
||||
shaky.freehand=true; shaky.ends=ks::LineEnds::Both;
|
||||
shaky.points={{20,50},{19,50},{40,50},{120,50},{141,50},{140,50}};
|
||||
auto shakyImage=ks::screenshotClip(dc,160,100,{shaky});
|
||||
require(readPixel(shakyImage,28,47)==shaky.color && readPixel(shakyImage,12,47)==RGB(255,255,255),
|
||||
"start arrowhead points outward despite initial mouse jitter");
|
||||
require(readPixel(shakyImage,132,47)==shaky.color && readPixel(shakyImage,148,47)==RGB(255,255,255),
|
||||
"end arrowhead points outward despite final mouse jitter");
|
||||
for(auto style:{ks::LineStyle::Solid,ks::LineStyle::Dotted,ks::LineStyle::Dashed,ks::LineStyle::DashDot}) {
|
||||
line.style=style;
|
||||
auto image=ks::screenshotClip(dc,160,100,{line});
|
||||
int ink=0,gaps=0;
|
||||
for(int x=25;x<135;++x) { if(readPixel(image,x,50)==line.color) ++ink; else ++gaps; }
|
||||
require(ink>0 && (style==ks::LineStyle::Solid?gaps==0:gaps>0),"line pattern exports visible ink and expected gaps");
|
||||
for(auto &previous:styleImages) require(previous!=image.formats[0].data,"line styles have distinct output");
|
||||
styleImages.push_back(image.formats[0].data);
|
||||
}
|
||||
ks::ScreenshotMark curve{{20,80},{140,30},0}; curve.freehand=true; curve.ends=ks::LineEnds::None;
|
||||
curve.points={{20,80},{50,20},{90,70},{140,30}}; curve.color=RGB(0,80,220);
|
||||
auto curveImage=ks::screenshotClip(dc,160,100,{curve});
|
||||
require(!curve.isHighlight() && readPixel(curveImage,50,20)==curve.color,"freehand line exports opaque curved path");
|
||||
require(ks::hitScreenshotMark(curve,{50,20},3)==ks::MarkPart::Body,"curve hit test follows actual stroke");
|
||||
auto curveMoved=ks::dragScreenshotMark(curve,ks::MarkPart::Body,{50,20},{55,25},160,100);
|
||||
require(curveMoved.points[1].x==55 && curveMoved.points[1].y==25,"curve movement preserves all intermediate points");
|
||||
auto curveEnd=ks::dragScreenshotMark(curve,ks::MarkPart::End,curve.to,{150,40},160,100);
|
||||
require(curveEnd.points.back().x==150 && curveEnd.points.back().y==40 && curveEnd.points[1].y==20,
|
||||
"curve endpoint editing preserves intermediate shape");
|
||||
curve.style=ks::LineStyle::DashDot; curve.ends=ks::LineEnds::Both;
|
||||
auto curveCopy=ks::cloneScreenshotMark(curve,{curve},160,100);
|
||||
require(curveCopy.freehand && curveCopy.style==curve.style && curveCopy.ends==curve.ends,"curve cloning preserves style and arrowheads");
|
||||
ks::ScreenshotHistory lineHistory;
|
||||
std::vector<ks::ScreenshotMark> lines{curve}; lineHistory.remember(lines);
|
||||
lines[0].style=ks::LineStyle::Dotted; lines[0].ends=ks::LineEnds::None;
|
||||
require(lineHistory.undo(lines) && lines[0].style==ks::LineStyle::DashDot && lines[0].ends==ks::LineEnds::Both,
|
||||
"undo restores line pattern and endpoints");
|
||||
require(lineHistory.redo(lines) && lines[0].style==ks::LineStyle::Dotted && lines[0].ends==ks::LineEnds::None,
|
||||
"redo restores line pattern and endpoints");
|
||||
SelectObject(dc,old); DeleteObject(bitmap); DeleteDC(dc);
|
||||
}
|
||||
auto event=[](DWORD vk,DWORD scan=0,bool own=false) {
|
||||
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
|
||||
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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>
|
||||
int main()
|
||||
{
|
||||
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
|
||||
try {
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||
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(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
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);
|
||||
}
|
||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no 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"Freehand")!=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"Freehand")!=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;
|
||||
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);
|
||||
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");
|
||||
DestroyWindow(app.window); app.window=nullptr;
|
||||
std::filesystem::remove_all(temp);
|
||||
std::cout<<"Passed screenshot editor UI integration checks\n";
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||
}
|
||||
@@ -428,6 +428,16 @@ int main(int argc, char **argv)
|
||||
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
|
||||
"elevated autostart is opt-in for existing and default settings");
|
||||
auto invalid = settings;
|
||||
auto captureSettings = settings;
|
||||
captureSettings.screenshot = L"Ctrl+Shift+F9";
|
||||
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
|
||||
"screen capture shortcut roundtrip");
|
||||
captureSettings.screenshot = captureSettings.clipboard;
|
||||
rejects([&] { ks::validateSettings(captureSettings); }, "screen capture shortcut duplicates rejected");
|
||||
captureSettings.screenshot.clear();
|
||||
ks::validateSettings(captureSettings);
|
||||
require(ks::loadSettings(temp / L"defaults.json", {}).screenshot == L"Win+Shift+S",
|
||||
"screen capture default for existing settings");
|
||||
invalid.undo = invalid.toggle;
|
||||
rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected");
|
||||
invalid = settings;
|
||||
|
||||
Reference in New Issue
Block a user