Win32 — F12: Cursors
Findings from ./examples/f12-cursors/app.d, the Win32 implementation of the F12 spec: a 3×3 hover-zone grid answered from WM_SETCURSOR with SetCursor (LoadCursorW system shapes + one custom ARGB bullseye from CreateIconIndirect), a 12-stop SetCursorPos tour that drives the WM_MOUSEMOVE → WM_SETCURSOR cascade from inside the demo, and a class-cursor precedence probe. Two headline answers: Win32's resize vocabulary is four bidirectional shapes for eight edges (IDC_SIZENWSE/NESW/WE/NS — no per-edge cursors), and in the precedence probe the class cursor wins whenever WM_SETCURSOR falls through to DefWindowProcW — a SetCursor immediately before the fall-through is silently overwritten.
Last reviewed: June 11, 2026
IMPORTANT
Everything observed below is A[wine] — measured under Wine 10.0 with the exe cross-compiled by LDC 1.41.0 (-mtriple=x86_64-pc-windows-msvc). Two runs: winewayland against the live wayland-0 session and winex11 under xvfb-run (WAYLAND_DISPLAY unset, private XDG_RUNTIME_DIR); both produced identical zone, storm, and precedence results. Cursor requests are what the log proves; the rendered pixels (who composites the cursor) need eyes — the visual pass rides the manual-run queue.
The demo
The client area is a 3×3 grid: the 8 border cells map the 8 resize edges onto the 4 resize shapes, and the center cell is subdivided 2×2 into IDC_ARROW / IDC_IBEAM / IDC_HAND / the custom bullseye. The window class registers hCursor = IDC_CROSS as the precedence-probe foil. A WSI_AUTO_EXIT=1 run (~2 s) executes the 12-stop tour, then the precedence phases, then exits 0.
All 9 cursors loaded successfully in both runs (cursor_loaded … handle=… for the 7 IDC_* shapes, IDC_CROSS, and the CreateIconIndirect bullseye); GetSystemMetrics(SM_CXCURSOR/SM_CYCURSOR) answered 32×32 — the "nominal width of a cursor, in pixels" (GetSystemMetrics).
Driving the pointer without a hand — A[wine]
winewayland offers no external warp tool, but SetCursorPos — "[m]oves the cursor to the specified screen coordinates" — goes through wineserver's virtual cursor and works from inside the demo; each warp produced exactly one WM_MOUSEMOVE whose DefWindowProcW pass generated exactly one WM_SETCURSOR:
115924 tour_warp zone=nw client=78,47 screen=82,77
116040 wm_setcursor n=1 hittest=1 trigger=0x200 phase=normal
116108 cursor_set name=IDC_SIZENWSE zone=nw
...
499817 tour_warp zone=se client=393,238 screen=397,268
499968 cursor_set name=IDC_SIZENWSE zone=se ← same shape as nw: 4 shapes, 8 edges
...
980028 tour_warp zone=c_ibeam client=267,123 screen=271,153
980170 cursor_set name=IDC_IBEAM zone=c_ibeam
1171599 cursor_set name=custom_bullseye zone=c_custom
1460289 summary wm_setcursor=15 wm_mousemove=15- The storm is 1:1 with mouse movement.
summary wm_setcursor=15 wm_mousemove=15(16/16 in the X11 run, which gets one extra pair because Xvfb's pointer starts over the window — firstcursor_set … zone=sebefore any warp). EveryWM_SETCURSORcarriedhittest=1(HTCLIENT) andtrigger=0x200(WM_MOUSEMOVE) — the message is per-move, not per-zone-change, so a real interactive drag re-answers it continuously and the handler must be cheap and idempotent. The demo resolves the zone and callsSetCursoron every message, loggingcursor_setonly on zone change. - The shape vocabulary: the 8 edge stops resolved to only 4 distinct shapes —
nw/se→IDC_SIZENWSE,ne/sw→IDC_SIZENESW,w/e→IDC_SIZEWE,n/s→IDC_SIZENS. Win32 has no per-edge resize cursors (contrast Wayland'scursor-shape-v1with its eight directional*_resizenames); a cross-platform shape enum must fold pairs when targeting Win32.
Precedence: who sets the cursor — A[wine]
The contract under test, verbatim: "The DefWindowProc function also uses this message to set the cursor to an arrow if it is not in the client area, or to the registered class cursor if it is in the client area" (WM_SETCURSOR). Three phases, each ending with a fresh warp to force a WM_SETCURSOR and a GetCursor sample a few timer ticks later:
1267843 cursor_set name=IDC_HAND zone=probe then=DefWindowProcW
1331655 precedence_result phase=set_then_def cursor_after=IDC_CROSS
1395545 precedence_result phase=class_only cursor_after=IDC_CROSS
1459597 precedence_result phase=normal cursor_after=IDC_HANDSetCursorthenDefWindowProcW: the class cursor wins. The hand was set and then silently replaced byIDC_CROSSinside the same message —DefWindowProcWre-applies the class cursor unconditionally forHTCLIENT. Code that "sets the cursor and returns whatever" flickers or loses; the only correct shape isSetCursor+ returnTRUEwithout callingDefWindowProc(thephase=normalrow, whereIDC_HANDsurvived).- Equivalently: a non-null class
hCursorand aWM_SETCURSORhandler are mutually exclusive strategies. Bindings that switch cursors at runtime should registerhCursor = null-or-arrow and ownWM_SETCURSOR; the class cursor is the static-app convenience. SetSystemCursor(replacing a system cursor image machine-wide) is deliberately not exercised — it mutates host-global state.
Custom, animated, and DPI — A[wine]
- Custom ARGB cursor: a 32×32 bullseye with hotspot
(16,16)built from a 32bpp top-down DIB (CreateDIBSection) plus an all-zero monochrome mask, throughCreateIconIndirectwithICONINFO.fIcon=FALSE— "[a] value of TRUE specifies an icon; FALSE specifies a cursor", and only cursors honor the hotspot fields. Creation succeeded and thec_customzone set it like any system shape. One-liner on the legacy API:CreateCursortakes only monochrome AND/XOR bit planes — alpha-blended cursors require theCreateIconIndirect(orLoadIconWithScaleDown-era) path. - Animated cursors: absent under Wine.
LoadCursorFromFileW("[t]he data in the file must be in either .CUR or .ANI format") onC:\windows\cursors\aero_busy.anifailed witherr=3(ERROR_PATH_NOT_FOUND), and a host-sidefindover the fresh prefix'sdrive_c/windowsfound no.ani, no.cur, not even acursors\directory — Wine prefixes ship no cursor files at all; Wine's system cursors come from the host theme via the display driver. The.anicode path is therefore Windows-only territory (Tier C). - DPI:
SM_CXCURSOR/SM_CYCURSORreport 32×32 — a nominal size. On Windows 10+ the effective cursor size follows the system cursor-size accessibility setting and per-monitor DPI scaling of the cursor image is done by the system when the cursor was loaded with the right metrics (LoadCursorWresources carry multiple sizes; see About cursors); there is no per-window cursor-size API, and under Wine at 96 DPI nothing scale-dependent was observable. Live mixed-DPI behavior (does the arrow grow on the 150 % monitor?) is unreachable here — Tier C, with the F08 caveat that Wine never varies DPI per monitor anyway.
Surprises
DefWindowProcWoverwrites a just-set cursor (set_then_def→IDC_CROSS): the class cursor is not a fallback, it is an unconditional re-application — the return-TRUEdiscipline is load-bearing, and a binding cannot mix "class cursor for most windows" with "occasionallySetCursorin the handler" on the same class.WM_SETCURSORdoes not exist without mouse messages. Changingg.phasedid nothing until the next warp generated aWM_MOUSEMOVE; an app that changes its desired cursor while the mouse is still must re-trigger (the idiomatic poke isSetCursorPos(GetCursorPos())or posting a syntheticWM_MOUSEMOVE, whichDefWindowProcWconverts into a freshWM_SETCURSOR).- A Wine prefix has zero cursor files —
LoadCursorW(null, IDC_*)succeeds for the whole standard vocabulary whileC:\windows\cursorsdoesn't exist; system-shape loading and file loading are entirely separate plumbing under Wine (A[wine]). - Identical results under winewayland and winex11 — zone mapping, 1:1 storm ratio, precedence winners, custom-cursor creation, missing
.ani— the cursor pipeline above the display driver is driver-independent in Wine 10.0.
Build & run — A[wine]
The scaffold's verified pipeline, run in docs/research/window-system-integration/os-apis/win32/examples/f12-cursors/:
nix develop .#win32 -c win32-ldc2 app.d instrument.d -of=build/f12-cursors.exe
# default (winewayland, live wayland-0)
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 \
nix develop .#win32 -c wine64 ./build/f12-cursors.exe
# winex11 under Xvfb (WAYLAND_DISPLAY unset, private XDG_RUNTIME_DIR)
env -u WAYLAND_DISPLAY XDG_RUNTIME_DIR=$(mktemp -d) WINEPREFIX=$(mktemp -d) \
WINEDEBUG=-all WSI_AUTO_EXIT=1 \
xvfb-run -a nix develop .#win32 -c wine64 ./build/f12-cursors.exeBoth modes exit 0. Without WSI_AUTO_EXIT=1 the demo runs until closed and the grid can be explored with a real mouse (the visual check queued as Tier C). The package's dub.sdl (platforms "windows") exists for the Windows CI runner; locally dub is not part of the pipeline.
Sources
./examples/f12-cursors/app.d— the demo (all log excerpts above)- F12 spec — requirements implemented here
- Win32 scaffold findings, F08 DPI findings
WM_SETCURSOR,SetCursor,LoadCursorW,SetCursorPos,GetCursor,CreateIconIndirect,ICONINFO,CreateCursor,LoadCursorFromFileW,SetSystemCursor,GetSystemMetrics, About cursors — Microsoft Learn (Wayback-pinned)