Skip to content

dawidope/WormsCursor

Repository files navigation

WormsCursor

WormsCursor icon

A tiny Windows tray utility that rotates the mouse cursor to follow your movement direction — like the aiming arrow in Worms 3D. Move the mouse right and the arrow points right; sweep it down and it smoothly slews to point down. The link/hand cursor rotates the same way.

It runs in the background from the system tray, with no main window.

WormsCursor: the arrow cursor rotating to follow mouse movement, with themed system cursors

⚠️ It replaces the system-wide arrow cursor (SetSystemCursor) while running, so every app sees the rotated arrow. The defaults are restored on quit and on a crash, and WormsCursor reloads the real cursor scheme on every launch — so if it's ever killed outright (Task Manager "End Task", Visual Studio "Stop Debugging") and the cursor stays rotated, just starting it again clears it. To fix it without relaunching, run tools/RestoreCursor.ps1 (logging off resets it too).

Demo

video.mp4

The cursor rotating to follow your mouse movement.

Cursors

WormsCursor themes all 14 standard system cursors, each with its own physics-driven motion:

The WormsCursor cursor set

Generated by the WormsCursor.Preview tool: dotnet run --project src/WormsCursor.Preview -- assets/cursors.png (writes a dark sheet plus a transparent one).

How it works

  • At startup the engine pre-renders 360 rotated copies of the arrow (one per degree) as cursors, each with the hotspot pinned to the arrow tip.
  • A background loop polls the cursor position (144 Hz by default) and computes the movement direction from accumulated travel, so ±1 px jitter doesn't make the arrow wobble on straight or vertical moves.
  • The displayed angle is animated toward the target direction (a fixed deg/s slew), so turns look smooth instead of snapping.
  • Both the arrow (OCR_NORMAL) and the hand/link cursor (OCR_HAND) rotate. The hand is drawn the same way as the arrow — a filled silhouette plus a pen-stroked outline, finger separators and knuckle creases — so it shares the arrow's colour, size and outline thickness (the geometry is baked into HandShape.cs).
  • The busy / progress cursors are themed too. App-starting (OCR_APPSTARTING) is the rotating arrow with a spinning ring of dots that hangs off its tail like a pendulum — swinging out as you move and settling when you stop — while wait (OCR_WAIT) is the same ring centred on the pointer (spin only). They animate only while actually on screen, so an idle tray costs nothing.
  • The help cursor (OCR_HELP) hangs a "?" upside-down off the arrow's tail on the same string, swinging to the sides as you move and settling when you stop.
  • The crosshair (OCR_CROSS) is a precision reticle — a centre dot, four ticks and a slowly-rotating broken ring; the ticks breathe and spread out (recoil) when you move fast, then settle.
  • The text / I-beam cursor (OCR_IBEAM) is a flexible beam: the bottom stays rigid while the top sways opposite to your motion on a soft spring, wobbling like jelly and settling.
  • The resize cursors (OCR_SIZEWE / SIZENS / SIZENWSE / SIZENESW) and the move cursor (OCR_SIZEALL) are stretched-taffy double-arrows — drag along an axis and the shaft necks thin while the heads fly apart on a spring, then blob back; move crosses a horizontal and a vertical taffy into a 4-way glyph.
  • The unavailable cursor (OCR_NO) is a red circle-with-slash whose ring is a jelly blob: it deforms into an egg along the direction of travel and wobbles back to round.
  • Alternate-select (OCR_UP) reuses the same rotating arrow.
  • The animated cursors re-render only while actually on screen (matched via GetCursorInfo against the live system handle), so an idle tray uses no CPU.

Agent notifier

When an AI coding agent needs your attention, the cursor sprouts a small logo charm that hangs and swings on the same pendulum as the busy ring / help "?" — one logo for the waiting tool, with a frameless "+N" when several agents wait at once. It's an ambient, peripheral-vision nudge: no toast, no extra window. When nothing's waiting it costs nothing — the cursor falls back to its plain pre-rendered frames.

Set it up from Preferences… → Agent settings…: register a tool there (currently Claude Code), toggle the logo, and set the "clear a stuck logo after" timeout. Registering writes WormsCursor's hook command into the tool's config (~/.claude/settings.json) with backup-and-merge — it never overwrites your own hooks. Each event then runs WormsCursor.exe hook --tool …, a throwaway process that writes one line to the running tray app over a named pipe and exits. It's fail-silent (<½ s pipe timeout, errors logged to bridge.log, always exits 0), so it can never block or break the agent.

What it reacts to. Each tool's lifecycle hooks are normalised to a few events, and the logo tracks whether the ball is in your court:

The agent… Claude hook Logo
is blocked on you (permission / idle prompt) Notification appears
finished its turn Stop appears
ended on an error StopFailure appears
started on your prompt, or you reopened the session UserPromptSubmit / SessionStart clears
exited cleanly (/exit, Ctrl+C, /clear, logout) SessionEnd clears at once

So it shows up when an agent is waiting on you and clears the moment you're clearly back (you submitted a prompt) or the session ends. Multiple sessions are tracked independently (keyed by tool

  • session id), so the count is simply "how many agents need you right now".

Why it's event-driven (and the timeout). WormsCursor only knows what the hooks tell it over the pipe — it never polls the agent or watches its process. That keeps it dead-simple and zero-cost when idle, but it means a session that never sends a closing event would otherwise wait forever. So there's a backstop: any waiting session with no further events for the linger timeout (default 60 s, configurable 30–1800 s) is swept out of the count. The tool-call hooks (PreToolUse / PostToolUse) are deliberately not registered — they'd spawn a hook process on every single tool call — so "you replied" is inferred from your next prompt, not from the agent resuming work.

Known issues

  • Non-standard cursors (grab / grabbing / zoom-in / zoom-out / cell) aren't themed and fall back to the Windows defaults. These aren't Windows system cursors — there's no OCR_* slot for them, so SetSystemCursor (how WormsCursor themes everything) has nothing to replace. They're CSS cursor values that the application draws itself: a browser handles WM_SETCURSOR and calls SetCursor with its own embedded cursor resource, bypassing the global system-cursor table entirely — so nothing we install there is ever seen. Any way to override them would necessarily be a hack: an owner-drawn overlay that hides the real cursor and paints our own (the same rearchitecture the mixed-DPI flicker would need), plus capturing and re-styling whatever bitmap the app set, since the handle we read back can't be reliably told apart (grab vs zoom vs cell). Outside a browser these cursors essentially never appear.
  • Animated cursors flicker on a mixed-DPI multi-monitor setup. If your monitors run at different display scales (e.g. 125% on one, 150% on another), the animated cursors (busy/wait, app-starting, help, crosshair, …) can strobe while shown on the higher-DPI screen. They animate by re-installing the cursor via SetSystemCursor every frame, and on a monitor whose scale differs from the one the cursor was created for, Windows re-composites the global cursor on each swap — which flashes. The static cursors (the rotating arrow/hand at rest) are re-installed only on a direction change, so they don't flicker. Single-DPI setups and matching scales are unaffected. Workarounds: set both monitors to the same scale, or enable Pointer trails (Settings → Bluetooth & devices → Mouse → Additional mouse settings → Pointer Options, shortest setting), which forces a cursor-draw path that sidesteps the flicker. A proper fix would mean drawing the animated cursors in our own overlay instead of through SetSystemCursor (tracked for a future release).
  • A hard-killed agent's logo lingers until the timeout. The agent notifier learns everything from the tool's hooks over the pipe — it never watches the agent's process. A clean exit (/exit, Ctrl+C, /clear, logout) fires SessionEnd and the logo clears immediately, but if the agent is killed outright (Task Manager "End Task", closing the terminal window, kill) no hook runs, so no "session ended" ever reaches WormsCursor. The waiting logo then stays until the linger timeout sweeps it (default 60 s, set in Agent settings…). This is by design — with no event there's nothing to react to, and the timeout is the backstop.

Project structure

WormsCursor.sln
├─ src/
│  ├─ WormsCursor.Core/      Engine — no UI dependencies
│  │   ├─ CursorEngine.cs     P/Invoke, cursor building, tracking + animation loop
│  │   ├─ ArrowRenderer.cs    Draws the arrow (size, colours, thickness, corner radius)
│  │   ├─ HandRenderer.cs     Draws the hand/link cursor (solid fill + baked line art)
│  │   ├─ ProgressRenderer.cs Draws the composited cursors (busy, help, crosshair, text, resize/move, unavailable)
│  │   ├─ HandShape.cs        Baked hand geometry (silhouette + crease marks)
│  │   ├─ NotifierRenderer.cs Composites the agent-notifier logo charm onto a cursor
│  │   ├─ AgentLogos.cs       Baked tool logos (Claude critter, OpenAI knot); SvgPath.cs parses the path data
│  │   ├─ AgentActivity.cs    Tracks which agent sessions need you (UI-free, thread-safe); AgentEventMessage.cs is the wire format
│  │   ├─ CursorSettings.cs   Tunable parameters (persisted as JSON)
│  │   └─ SettingsStore.cs    Load/save settings in %LocalAppData%\WormsCursor\
│  ├─ WormsCursor.App/       Tray shell (WinForms, no main window)
│  │   ├─ Program.cs                   Entry point (Velopack + single-instance guard; `hook` verb short-circuits first)
│  │   ├─ TrayApplicationContext.cs   NotifyIcon + menu, owns the engine, pipe server + keyboard hook
│  │   ├─ PreferencesForm.cs          Live settings dialog (preview grid, size/colour/thickness/radius, feedback toggles, test cursor + Showtime)
│  │   ├─ AgentHooksForm.cs            Agent-notifier settings + per-tool hook registration UI
│  │   ├─ AgentPipeServer.cs           Named-pipe server that receives normalised agent events
│  │   ├─ AgentHookBridge.cs           The `hook` verb: normalises a tool's payload → one pipe line, fail-silent
│  │   ├─ Services/AgentHookRegistrar.cs Writes/removes WormsCursor's hook in each tool's config (backup + merge)
│  │   ├─ LowLevelKeyboardHook.cs      Keyboard hook driving the I-beam typing bounce
│  │   ├─ SingleInstance.cs            One instance only; a 2nd launch opens Preferences
│  │   ├─ Autostart.cs                 "Start with Windows" via HKCU\…\Run
│  │   ├─ ChangelogForm.cs             "What's new" dialog (from CHANGELOG.md)
│  │   └─ Services/UpdateService.cs    Velopack check / download / apply updates
│  └─ WormsCursor.Preview/   Console tool — renders the cursor showcase PNGs (docs/README)
│      └─ Program.cs                    Dark + transparent sheets of every themed cursor
└─ tools/
   ├─ generate-icon.py       Builds Assets/Icon.ico (+icon.png) — the arrow glyph
   └─ RestoreCursor.ps1      Emergency restore of default cursors

The app/tray icon is the same arrow as a white glyph with a black frame (so it reads on both dark and light taskbars), generated by tools/generate-icon.py (Pillow). Regenerate after tweaking the shape/angle:

python tools/generate-icon.py

The engine (Core) is deliberately UI-agnostic, so the tray shell could later be swapped for WPF/WinUI — or packaged for the Microsoft Store — without touching the cursor logic.

Build & run

Requires the .NET 8 SDK (Windows). Open WormsCursor.sln in Visual Studio 2022+ and run, or from a terminal:

dotnet build WormsCursor.sln
dotnet run --project src/WormsCursor.App

A tray icon appears. Right-click it for Enabled / Preferences… / Exit; double-click toggles the effect on/off. Only one instance runs — launching it again just opens Preferences.

Preferences… opens a live editor with a preview grid of all the cursors — hover a tile to try it on the real pointer, or untick it to keep the Windows default. It has sliders for size, outline thickness and corner radius, fill and outline colour pickers, click-feedback and I-beam typing-bounce toggles, a Test cursor picker (plus Showtime, a hands-free cycle through the enabled cursors for recording), a Start with Windows toggle, an Agent settings… button, and a Check for updates button. "Start with Windows" registers a per-user HKCU\…\Run entry (no admin), which you can also manage from Task Manager → Startup apps. Settings are saved to %LocalAppData%\WormsCursor\settings.json and persist across restarts — and across updates, since they live outside the app folder.

Standalone build (no .NET install on the target)

dotnet publish src/WormsCursor.App -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true

Note: WinForms doesn't support trimming/NativeAOT, so a self-contained build is several tens of MB. A framework-dependent build is tiny but needs the .NET 8 Desktop Runtime installed.

Releases

Installers and standalone builds are produced by Velopack: pushing a v* tag runs the release workflow (.github/workflows/release.yml), which publishes a Setup.exe installer, a Portable.zip standalone, and delta packages to the repo's GitHub Releases. The app auto-updates from these releases (tray → Check for updates…, or the button in Preferences).

License

MIT © 2026 Dawid Wenderski

About

Rotating mouse cursor for Windows that follows your movement direction, Worms 3D style — a .NET 8 system-tray app.

Topics

Resources

License

Stars

61 stars

Watchers

3 watching

Forks

Packages

 
 
 

Contributors