Compare commits

..

5 Commits

16 changed files with 556 additions and 95 deletions
+25 -5
View File
@@ -75,9 +75,11 @@ physical key positions and therefore reset recent-word tracking; selection conve
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
The selected area is copied immediately and opens in the editor, even when Windows notifications
are disabled, suppressed or cannot be sent. Disable **Open editor after screen capture** in settings
to open it only by clicking the notification; an immediate notification error still opens the editor.
The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
to open it automatically; this option is off by default. An immediate notification error still opens the editor.
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
notification center without showing a banner.
The preference applies to each new capture, including while other editors are open.
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
at resident startup and refreshed before sending; registration/submission errors are written to the log.
@@ -88,8 +90,14 @@ broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
The editor starts with **Pointer (V)**: select objects, move them, and edit geometry, thickness and
other properties using handles and controls. Clicking empty space clears selection; dragging empty
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
cursors scale with display DPI and use a contrasting outline for light and dark images.
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
@@ -99,6 +107,14 @@ 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. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. 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
@@ -127,6 +143,8 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
automatic angle; only dragging the rotation handle switches to a fixed angle.
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
is reused; rotation belongs to each individual object.
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
@@ -158,7 +176,9 @@ Captures use ordinary clipboard history settings and limits. Protected video and
Select **Update** in the tray menu to check the stable Releases of
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
A startup check also reports an available version, but never installs it automatically.
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
It reports each available version once per session and never installs automatically.
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
Confirm the offered version to download and install it. The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
Binary file not shown.
+7 -10
View File
@@ -1,17 +1,14 @@
# Latest Windows release
## v0.6.2 — 2026-10-29
## v0.6.7 — 2026-10-29
- Add **Open editor after screen capture**, enabled by default. Disable it to use notification-first capture; an immediate notification error still opens the editor.
- Save the preference and apply it to each capture, including requests sent to an already running screenshot process.
- Register screenshot notification identity, shortcut and COM activation during resident startup, as well as before sending a notification.
- Log notification registration and submission errors when logging is enabled.
- Refresh clipboard history preview row heights when display DPI changes.
## Changes
- Show a distinct cursor for each screenshot editor tool: pointer, text caret, pencil, and crosshairs with symbols for numbers, arrows, straight lines, rectangles, circles, ellipses and highlights.
- Preserve action cursor priority for moving, resizing, changing thickness and rotating objects.
- Scale custom cursors with display DPI and add contrasting outlines for light and dark backgrounds. Tool changes refresh the cursor immediately.
Verified: 175 native checks, 397 input/layout checks at 240 DPI, screenshot editor UI integration,
isolated notification registration checks, and 45 updater checks. A physical transition between
monitors with different DPI has not been tested. Successful notification submission does not
guarantee a visible Windows banner; asynchronous toast failures are not yet monitored.
## Validation
176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. Cursor coverage verifies tool selection, action overrides, hotspots and masks at 32/48/64/96 pixels. Cursor previews were visually checked on light and dark backgrounds.
**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
View File
@@ -1 +1 @@
e0c5a7b4f225ee32790f01ec916ae4706783b7e50b0bf6816c38ae2a6685d43d KSwitcher-win-x64.zip
3c5fd91f163e54440fb144b5ef7c301695eb9d8df36dea6f807521b9c171a489 KSwitcher-win-x64.zip
+3 -3
View File
@@ -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,2,0
PRODUCTVERSION 0,6,2,0
FILEVERSION 0,6,7,0
PRODUCTVERSION 0,6,7,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.2"
VALUE "FileVersion", "0.6.7"
END
END
BLOCK "VarFileInfo"
+6 -3
View File
@@ -1254,8 +1254,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
app->checkUpdate(true, true, result->version);
}
else
else if (app->notifiedUpdate != result->version)
{
app->notifiedUpdate = result->version;
NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
@@ -1274,7 +1275,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
else if (result->interactive)
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
else
diagnosticEvent(L"Startup update check failed: " + result->error);
diagnosticEvent(L"Background update check failed: " + result->error);
return 0;
}
case WM_HOTKEY:
@@ -1338,7 +1339,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_TIMER:
if (wp == 5)
{
KillTimer(hwnd, 5);
// After the initial 5-second check, repeat while the resident stays open.
// Rearm even when a check is already running or the previous one failed.
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
app->checkUpdate(false);
return 0;
}
+1 -1
View File
@@ -85,7 +85,7 @@ class App
std::thread worker;
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate;
std::wstring availableUpdate, notifiedUpdate;
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
+1 -1
View File
@@ -48,7 +48,7 @@ struct Settings
bool elevatedAutostart = false;
bool autoCorrect = false;
bool writeLog = true;
bool screenshotAutoOpenEditor = true;
bool screenshotAutoOpenEditor = false;
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
+246 -62
View File
@@ -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>
@@ -51,7 +53,7 @@ bool confirmScreenshotClose(HWND owner)
// If Windows cannot display the confirmation, keep the screenshot open.
return SUCCEEDED(result) && button==IDYES;
}
constexpr int ToolIds[]{10,11,22,23,24,25,21,19,26,20,12,18,13};
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
struct Surface
{
HDC dc = CreateCompatibleDC(nullptr);
@@ -121,6 +123,44 @@ bool chooseTextFont(HWND owner,Mark &m)
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
return true;
}
constexpr int CursorTools[]{10,11,22,23,24,25,19};
HCURSOR makeToolCursor(int tool,int size)
{
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
auto bitmap=CreateBitmap(size,size*2,1,1,nullptr);
auto dc=CreateCompatibleDC(nullptr);
if(!bitmap || !dc) { if(bitmap) DeleteObject(bitmap); if(dc) DeleteDC(dc); return nullptr; }
auto oldBitmap=SelectObject(dc,bitmap);
PatBlt(dc,0,0,size,size,WHITENESS); PatBlt(dc,0,size,size,size,BLACKNESS);
auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
auto draw=[&](int offset,int width,COLORREF color) {
auto pen=CreatePen(PS_SOLID,width,color); auto oldPen=SelectObject(dc,pen);
auto x=[&](int v){return MulDiv(v,size,32);};
auto y=[&](int v){return offset+MulDiv(v,size,32);};
auto line=[&](int a,int b,int c,int d){MoveToEx(dc,x(a),y(b),nullptr); LineTo(dc,x(c),y(d));};
line(1,7,13,7); line(7,1,7,13); // The cross center is the exact drawing point.
switch(tool) {
case 10:
Ellipse(dc,x(17),y(17),x(30),y(30));
line(22,21,24,20); line(24,20,24,27); line(21,27,27,27); break;
case 11: line(18,29,29,18); line(22,18,29,18); line(29,18,29,25); break;
case 22: line(18,29,29,18); break;
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
case 19:
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
}
SelectObject(dc,oldPen); DeleteObject(pen);
};
int outline=std::max(3,MulDiv(4,size,32)),stroke=std::max(1,MulDiv(2,size,32));
draw(0,outline,RGB(0,0,0));
draw(size,outline,RGB(255,255,255)); draw(size,stroke,RGB(0,0,0));
SelectObject(dc,oldBrush); SelectObject(dc,oldBitmap); DeleteDC(dc);
ICONINFO info{}; info.fIcon=FALSE; info.xHotspot=info.yHotspot=MulDiv(7,size,32); info.hbmMask=bitmap;
auto cursor=(HCURSOR)CreateIconIndirect(&info); DeleteObject(bitmap); return cursor;
}
struct Capture
{
ScreenshotHost &app;
@@ -129,7 +169,7 @@ struct Capture
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
bool autoOpenEditor;
bool highlight{};
bool freehand=true;
bool pointer=true, freehand=false;
bool straight{}, textTool{};
Mark textDefaults{};
ScreenshotShape shape=ScreenshotShape::None;
@@ -157,6 +197,7 @@ struct Capture
HICON toolIcons[3][15]{};
int iconSize{};
HCURSOR pencilCursor{};
HCURSOR drawingCursors[std::size(CursorTools)]{};
int pencilCursorSize{};
int unit = 1, panelHeight = 1, statusHeight = 0;
int hoveredTool=0;
@@ -165,6 +206,7 @@ struct Capture
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor);
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
if (font) DeleteObject(font);
if(inlineFont) DeleteObject(inlineFont);
for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon);
@@ -197,7 +239,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)
@@ -205,11 +247,12 @@ void updateStatus(Capture &c)
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
}
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = draw over an object";
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object";
else if(c.arrow) text=L"Drag to draw | Ctrl+drag = draw over an object | F2 on a line = caption";
else text=L"Number (N): click to add | Drag objects to move | Ctrl+C = copy screenshot";
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
if(text!=c.statusText) {
c.statusText=std::move(text);
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
@@ -250,6 +293,15 @@ void positionInlineText(Capture &c)
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
}
Mark textStyleDefaults(const Mark &source)
{
// Reuse typography only, never an object's angle, position, identity or links.
Mark style{};
style.fontName=source.fontName; style.fontSize=source.fontSize;
style.fontWeight=source.fontWeight; style.fontItalic=source.fontItalic;
style.color=source.color;
return style;
}
void finishInlineText(HWND w,Capture &c,bool commit)
{
if(!c.inlineText) return;
@@ -272,8 +324,9 @@ void finishInlineText(HWND w,Capture &c,bool commit)
} else if(!empty) {
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
}
if(!empty) c.textDefaults=c.textDraft;
if(!empty) c.textDefaults=textStyleDefaults(c.textDraft);
}
if(commit) resolveScreenshotLinks(c.marks);
c.textIndex=-1; c.editingLabel=false;
InvalidateRect(w,nullptr,FALSE);
}
@@ -510,6 +563,11 @@ void layout(HWND w, Capture &c)
int dpi = GetDpiForWindow(w);
int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi);
if(cursorSize!=c.pencilCursorSize) {
for(size_t i=0;i<std::size(CursorTools);++i) {
auto cursor=makeToolCursor(CursorTools[i],cursorSize);
if(c.drawingCursors[i]) DestroyCursor(c.drawingCursors[i]);
c.drawingCursors[i]=cursor;
}
auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0);
if(cursor) {
if(c.pencilCursor) DestroyCursor(c.pencilCursor);
@@ -536,7 +594,7 @@ void layout(HWND w, Capture &c)
RECT statusRect{}; GetWindowRect(status,&statusRect);
c.statusHeight=statusRect.bottom-statusRect.top;
r.bottom-=c.statusHeight;
const int positions[]{8,44,80,116,152,188,224,260,296,332,380,416,464};
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
static_assert(std::size(positions)==std::size(ToolIds));
for (size_t i = 0; i < std::size(ToolIds); ++i)
{
@@ -575,6 +633,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];
@@ -596,30 +663,41 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
}
return {-1,MarkPart::None};
}
HCURSOR toolCursor(const Capture &c)
{
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
if(c.freehand && c.pencilCursor) return c.pencilCursor;
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
for(size_t i=0;i<std::size(CursorTools);++i)
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
return LoadCursorW(nullptr,IDC_CROSS);
}
HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
{
if(!c.editing) return LoadCursorW(nullptr,IDC_CROSS);
LPCWSTR cursor=nullptr;
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
const auto &mark=c.marks[hit.first];
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis];
}
return cursor?LoadCursorW(nullptr,cursor):inside?toolCursor(c):LoadCursorW(nullptr,IDC_ARROW);
}
void captureCursor(HWND w, Capture &c)
{
LPCWSTR cursor=IDC_CROSS;
if(c.editing)
{
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
auto hit=c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false));
if(!c.dragging && !PtInRect(&c.view,p) && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
if(!c.dragging && !PtInRect(&c.view,p)) cursor=IDC_ARROW;
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness)
{
const auto &mark=c.marks[hit.first];
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis];
}
}
SetCursor(c.editing && c.freehand && cursor==IDC_CROSS && c.pencilCursor?c.pencilCursor:LoadCursorW(nullptr,cursor));
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
bool inside=PtInRect(&c.view,p)!=FALSE;
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
SetCursor(actionCursor(c,hit,inside || c.dragging));
if(c.editing) {
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
c.hoveredPart=markAt(c,imagePoint(c,p,false)).second;
@@ -686,7 +764,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
{
auto dc=item.hDC;
RECT r=item.rcItem;
bool active=(item.CtlID==10 && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
bool disabled=item.itemState&ODS_DISABLED;
@@ -702,7 +780,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
auto icon=c.toolIcons[disabled?2:active?1:0][iconIndex];
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
if(item.CtlID==20) {
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
@@ -763,7 +841,7 @@ void edit(HWND w, Capture &c)
InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
const wchar_t *labels[]{L"Number (N)", L"Arrow (A)", L"Straight line (L)", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
static_assert(std::size(labels)==std::size(ToolIds));
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
@@ -815,6 +893,9 @@ void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=write
c.copied=true;
// A successful toast submission does not guarantee that Windows shows a banner.
if(c.autoOpenEditor) edit(w,c);
// Leave fullscreen capture before submitting the toast: Windows may suppress
// banners while a fullscreen application is visible.
else ShowWindow(w,SW_HIDE);
bool notified=c.app.notify(clip);
if(!c.autoOpenEditor) {
if(notified) DestroyWindow(w);
@@ -852,7 +933,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
break;
case WM_DRAWITEM:
if((wp>=10 && wp<=13) || (wp>=18 && wp<=26) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
break;
case WM_CTLCOLORSTATIC:
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
@@ -879,7 +960,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
auto draft=c->marks[c->selected];
if(chooseTextFont(w,draft)) {
c->remember(c->marks); c->marks[c->selected]=draft;
c->textDefaults=draft.isLine()?screenshotArrowLabel(draft):draft;
c->textDefaults=textStyleDefaults(draft);
resolveScreenshotLinks(c->marks);
}
} else chooseTextFont(w,c->textDefaults);
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
@@ -933,7 +1015,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)
@@ -942,8 +1024,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
c->syncHistory();
}
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26))
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
{
c->pointer=LOWORD(wp)==27;
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
c->straight=LOWORD(wp)==22;
c->textTool=LOWORD(wp)==26;
@@ -957,10 +1040,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
for(int id=22;id<=26;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
}
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
case WM_KEYDOWN:
if (wp==VK_ESCAPE) {
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
@@ -993,6 +1076,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(w,nullptr,FALSE);
}
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
if(wp=='V') SendMessageW(w,WM_COMMAND,27,0);
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
@@ -1052,6 +1136,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(w,nullptr,FALSE); return 0;
}
c->selected=-1; c->selectedLabel=false;
if(c->pointer) { InvalidateRect(w,nullptr,FALSE); updateStatus(*c); return 0; }
}
if(c->editing && c->textTool) {
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
@@ -1123,7 +1208,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);
@@ -1350,27 +1436,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);
}
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
const std::vector<ScreenshotMark> &marks, double tolerance)
namespace {
void assignMarkIds(std::vector<ScreenshotMark> &marks)
{
auto &link=start?arrow.fromNumber:arrow.toNumber;
link=0;
if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return;
for(auto it=marks.rbegin();it!=marks.rend();++it)
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance)
{
link=it->number;
break;
static unsigned long long next=1;
for(const auto &m:marks) next=std::max(next,m.id+1);
std::unordered_set<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,
std::vector<ScreenshotMark> &marks, double tolerance)
{
assignMarkIds(marks);
auto &number=start?arrow.fromNumber:arrow.toNumber;
auto &link=start?arrow.fromLink:arrow.toLink;
number=0; link={};
if(!arrow.isLine()) return;
// Number badges are drawn above other objects.
for(auto it=marks.rbegin();it!=marks.rend();++it)
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) {
number=it->number; return;
}
auto wouldCycle=[&](unsigned long long id) {
std::vector<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;
@@ -1380,17 +1546,32 @@ 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;
auto from=badge(m.fromNumber),to=badge(m.toNumber);
if(!from) m.fromNumber=0;
if(!to) m.toNumber=0;
POINT start=from?from->from:m.from, end=to?to->from:m.to;
if(from) m.from=rim(*from,end,true);
if(to) m.to=rim(*to,start,false);
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
}
std::vector<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;
POINT start=from?from->from:m.from, end=to?to->from:m.to;
if(from) m.from=rim(*from,end,true);
if(to) m.to=rim(*to,start,false);
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
}
state[index]=2;
};
for(size_t i=0;i<marks.size();++i) resolve(i);
}
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
{
@@ -1458,6 +1639,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);
@@ -1493,6 +1675,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()) {
@@ -1512,6 +1695,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));
+8 -2
View File
@@ -8,7 +8,7 @@ struct ScreenshotHost {
HWND window{};
unsigned windows{};
bool (*notify)(const Clip &){};
bool autoOpenEditor = true;
bool autoOpenEditor = false;
};
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &);
@@ -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
View File
@@ -47,7 +47,7 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
{
std::wstring token;
bool autoOpenEditor=true, writeLog=false;
bool autoOpenEditor=false, writeLog=false;
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
writeLog=true; --argc;
+1 -1
View File
@@ -151,7 +151,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", true);
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
if (j.contains("layouts"))
{
s.cycle.clear();
+2 -1
View File
@@ -2,8 +2,9 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.2";
inline constexpr wchar_t AppVersion[] = L"0.6.7";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
struct UpdateResult
{
std::wstring state, version, error;
+82 -1
View File
@@ -7,6 +7,20 @@
namespace ks {
struct InputRegressionAccess {
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static bool updateTimer(App &a) {
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
App::windowProc(a.window,WM_TIMER,5,0);
bool first=KillTimer(a.window,5)!=FALSE;
App::windowProc(a.window,WM_TIMER,5,0);
bool repeated=KillTimer(a.window,5)!=FALSE;
a.updateRunning=false;
return first && repeated && !a.updateWorker.joinable();
}
static std::wstring updateResult(App &a,const wchar_t *state,const wchar_t *version) {
auto result=new UpdateResult; result->state=state; result->version=version;
App::windowProc(a.window,UpdateMessage,0,(LPARAM)result);
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
};
}
@@ -31,6 +45,12 @@ 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");
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
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);
@@ -159,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);
@@ -509,7 +590,7 @@ int main(int argc, char **argv) {
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"DPI refresh preserves history list geometry");
}
auto settings=app.settingsWindow;
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_CHECKED,"automatic screenshot editor checkbox defaults on");
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
RECT first{},second{},overlap{};
+168 -2
View File
@@ -31,16 +31,58 @@ int main()
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)==L"KSwitcher — Screenshot #1","title includes editor index 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"Freehand")!=std::wstring::npos,"default tool hint is shown");
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"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
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;
@@ -94,6 +136,124 @@ 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");
}
{
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});
@@ -165,8 +325,12 @@ int main()
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}) {
@@ -178,6 +342,8 @@ int main()
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");
+4 -1
View File
@@ -430,7 +430,10 @@ int main(int argc, char **argv)
auto invalid = settings;
auto captureSettings = settings;
captureSettings.screenshot = L"Ctrl+Shift+F9";
require(captureSettings.screenshotAutoOpenEditor,"editor opens by default");
require(!captureSettings.screenshotAutoOpenEditor,"notification-first capture is the default");
captureSettings.screenshotAutoOpenEditor=true;
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
"explicit automatic editor preference survives settings roundtrip");
captureSettings.screenshotAutoOpenEditor=false;
require(!ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
"notification-first preference survives settings roundtrip");