Release v0.6.7: add DPI-aware cursors for screenshot tools

This commit is contained in:
2026-10-29 18:17:54 +02:00
parent 8fedb54dfe
commit 95c7199027
8 changed files with 134 additions and 33 deletions
+3
View File
@@ -93,6 +93,9 @@ In the editor, **Ctrl+C** or **Enter** copies the current result without closing
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. 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
Binary file not shown.
+5 -5
View File
@@ -1,14 +1,14 @@
# Latest Windows release
## v0.6.6 — 2026-10-29
## v0.6.7 — 2026-10-29
## Changes
- Add **Pointer (V)** as the initial editor tool. Select and move objects, edit geometry and thickness with handles, and clear selection by clicking empty space. Pointer never creates objects on empty space.
- Preserve **Ctrl+drag** for drawing over existing objects and automatically linking connector endpoints. Add clearer Ctrl hints to the Arrow and Straight line buttons and status bar.
- Fix new standalone text inheriting the angle of an arrow caption. New text reuses typography only and starts horizontally; editing existing rotated text preserves its own angle.
- 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.
## Validation
176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. UI coverage includes Pointer interactions, Ctrl drawing between markers/shapes/arrows, and standalone text after automatic or manually rotated arrow captions.
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 @@
cb711ace317e60054f6498436f02bd66f10adebfe9362b8d1c106569cd27a600 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,6,0
PRODUCTVERSION 0,6,6,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.6"
VALUE "FileVersion", "0.6.7"
END
END
BLOCK "VarFileInfo"
+67 -11
View File
@@ -123,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;
@@ -159,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;
@@ -167,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);
@@ -523,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);
@@ -618,20 +663,24 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
}
return {-1,MarkPart::None};
}
void captureCursor(HWND w, Capture &c)
HCURSOR toolCursor(const Capture &c)
{
LPCWSTR cursor=c.editing && c.pointer?IDC_ARROW:IDC_CROSS;
if(c.editing)
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)
{
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(!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)
{
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));
@@ -640,8 +689,15 @@ void captureCursor(HWND w, Capture &c)
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);
}
SetCursor(c.editing && c.freehand && cursor==IDC_CROSS && c.pencilCursor?c.pencilCursor:LoadCursorW(nullptr,cursor));
void captureCursor(HWND w, Capture &c)
{
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;
@@ -987,7 +1043,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
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.
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.6";
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
+42
View File
@@ -31,6 +31,48 @@ 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);