mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-20 02:17:11 +00:00
Windows-only floating, draggable circular bubble showing Claude Code and Codex usage. Derivative of CodeZeno/Claude-Code-Usage-Monitor (MIT), relicensed under Apache 2.0 with upstream attribution in NOTICE. - Ported verbatim: poller, updater, tray_icon, theme, localization, diagnose, models (~2,700 LOC) - Original: bubble (circular layered window, drag-anywhere via WM_NCHITTEST+HTCAPTION, snap-to-edge, Ctrl+Wheel resize, auto-hide on fullscreen), panel (expanded 5h/7d view), app (orchestrator, single-instance mutex, polling thread, context menu, dual-bubble lifecycle), settings (settings.json persistence) - Cargo.toml features cover Win32 GDI, HiDpi, Registry, Threading, Shell, WindowsAndMessaging, and KeyboardAndMouse
9.2 KiB
9.2 KiB
Phase 3: Build Floating Bubble Window
Context Links
- Source
window.rspainting + drag logic:/config/workspace/CodeZeno/Claude-Code-Usage-Monitor/src/window.rs(lines aroundUpdateLayeredWindow,WM_LBUTTONDOWN,SetCapture) - Reference UX: 360 Security floating ball, IObit Advanced SystemCare RAM-boost ball — both are circular, top-most, draggable-anywhere with edge snap.
Overview
- Priority: Critical — this is the heart of the new UX
- Status: pending
- Description: Build a circular floating bubble window that floats on top of everything, can be dragged anywhere, snaps to monitor edges, and shows usage percentage in the center over a colored progress ring. Replaces the 2847-line
window.rstaskbar embedding code.
Key Insights
- Use
WS_POPUP | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW—TOOLWINDOWkeeps it out of Alt+Tab;NOACTIVATEprevents focus theft. - Drag-anywhere = handle
WM_NCHITTEST, returnHTCAPTIONfor the entire bubble area. The OS handles drag automatically, including with proper cursor and Win+drag behavior. No need forSetCapture. - Circular alpha mask: render to a DIB section with per-pixel alpha. Pixels outside the circle =
0x00000000(fully transparent). ThenUpdateLayeredWindowwithULW_ALPHA. Click-through outside the circle happens automatically because alpha=0 doesn't hit-test (default behavior of layered windows withWS_EX_LAYERED+ per-pixel alpha; verify via test). - Snap to edge: in
WM_EXITSIZEMOVE, query current position viaGetWindowRect, find nearest monitor viaMonitorFromPoint, get its work area viaGetMonitorInfo. If center is within 12px of any work-area edge, snap that edge. - HiDPI: use
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)early inmain. QueryGetDpiForWindowonWM_DPICHANGEDand rescale bubble size + font. - GDI ring drawing: parametric — sweep angle proportional to percentage. Use
Polygonfilled with brush, orAngleArcwith thick pen. For clean anti-aliased look on layered window, draw into DIB section manually with alpha-weighted line algorithm. Simpler path: use GDI+ viagdipluscrate, or accept GDI-aliased look for v1. - Bubble size default: 56×56 px at 100% DPI (matches reference apps). Allow user to tweak in
Cargo.tomlconstant for v1.
Requirements
Functional
- Window appears as a circular bubble, can be dragged anywhere on any monitor
- Bubble shows percentage text (e.g. "73%") in center
- Colored progress ring around the percentage; color matches the source app's color stops (orange → red gradient from 50% to 100%)
- Top-most: stays visible over other windows
- No taskbar entry, no Alt-Tab entry
- Left-click → posts
WM_APP_PANEL_TOGGLEto self (panel implementation in phase 4) - Right-click → context menu (phase 4 owns menu items; bubble owns the right-click detection)
- Drag releases → snap to nearest monitor edge if within 12 px of it
- Repaints when percentage / theme / DPI changes
Non-functional
- 60 FPS not required; redraws on data update only (every 60s poll cycle plus countdown ticks)
- Per-monitor DPI aware
- Visible on dark and light Windows themes (use
theme::is_dark_modefor ring background tint)
Architecture
src/
├── bubble.rs NEW — Window class, message loop owner, GDI painting,
│ drag + snap, DPI, hit-testing
└── (other modules unchanged from phase 2)
Bubble owns:
- HWND lifecycle (
RegisterClassExW+CreateWindowExW) - DIB section +
UpdateLayeredWindowcall - Percentage state (
Option<f64>for each enabled model) - Drag state (managed by OS via
HTCAPTION) - Snap math
- DPI scale factor cache
Bubble delegates:
- Polling →
poller::poll(phase 4 wires the background thread) - Panel toggle →
app::on_panel_toggle(phase 4) - Right-click menu →
app::on_show_context_menu(phase 4) - Settings →
settingsmodule (phase 4)
Related Code Files
To create:
src/bubble.rs(target: 400–700 lines)
To modify:
src/main.rs— eventually callbubble::run()(wired in phase 4)src/native_interop.rs— may add helpers if hit-testing geometry math gets gnarly
To delete: none.
Implementation Steps
- Window class registration:
- Class name:
ClaudeCodeUsageBubble - Style:
CS_DBLCLKS(allowWM_LBUTTONDBLCLKif we want double-click later) - WndProc:
bubble_wnd_proc
- Class name:
- Window creation:
WS_POPUP, extWS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW- Initial position: load from settings (phase 4); fall back to "near bottom-right corner of primary monitor"
- Size: 56×56 logical px scaled by current DPI
- DPI awareness:
- In
bubble::run, callSetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2). - On
WM_CREATE, cache DPI viaGetDpiForWindow. - On
WM_DPICHANGED, update scale and resize.
- In
- Painting (the hard part):
- On every state change (percentage, DPI, theme), call
redraw(). redraw():- Create DIB section sized to bubble pixel dimensions (
CreateDIBSectionwithBI_RGBand 32bpp). - Clear to fully transparent (
0x00000000). - For each pixel inside the circle radius, write background fill (theme-adjusted: dark theme → semi-opaque dark with high alpha; light theme → semi-opaque white).
- Stroke progress ring: for the sweep angle proportional to current percentage, draw a thick arc using either GDI
AngleArcwith roundedPen, or manual pixel writes (4 px ring thickness at 100% DPI). - Draw percentage text in center via
DrawTextWwithDT_CENTER | DT_VCENTER | DT_SINGLELINE. Font: bold 14 pt at 100% DPI, scaled by DPI factor. - Call
UpdateLayeredWindowwithULW_ALPHAand the DIB.
- Create DIB section sized to bubble pixel dimensions (
- On every state change (percentage, DPI, theme), call
- Drag-anywhere via
WM_NCHITTEST:OS handles drag + cursor.WM_NCHITTEST => { // Convert lparam (screen coords) to client coords let p = screen_to_client(hwnd, lparam); if inside_circle(p, radius) { LRESULT(HTCAPTION as isize) } else { LRESULT(HTTRANSPARENT as isize) } }HTTRANSPARENToutside the circle ensures clicks pass through. - Snap on drag release:
WM_EXITSIZEMOVE→ snap logic.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)→GetMonitorInfo→ work area rect.- Compare bubble's center to each edge of work area. If distance < 12 logical px (scaled by DPI), adjust window position to snap.
- Persist new position via
app::on_bubble_moved(model, x, y)(phase 4).
- Click handling:
WM_LBUTTONUP→ if no drag occurred (compare withWM_LBUTTONDOWNposition),PostMessageW(WM_APP_PANEL_TOGGLE).WM_RBUTTONUP→ call intoapp::show_context_menu(hwnd, screen_pos)(phase 4 implements).
- Public API:
For dual-bubble mode, phase 4 spawns one
pub fn run(initial: BubbleConfig) -> ! { /* never returns; spins message loop */ } pub struct BubbleConfig { pub model: TrayIconKind, // Claude or Codex pub initial_position: Option<(i32, i32)>, pub initial_percentage: Option<f64>, } pub fn update_percentage(hwnd: HWND, percentage: Option<f64>); // called from poll thread via PostMessagebubble::runper enabled model on separate threads (each with its own message loop) — simpler than juggling two HWNDs in one thread.
Todo List
- Window class + creation with correct styles
- Per-monitor DPI awareness on entry
- DIB section + layered window painting pipeline
- Circle fill with theme-aware background
- Progress ring painted at correct sweep angle, correct color stop
- Percentage text drawn centered
WM_NCHITTESTreturnsHTCAPTIONinside circle,HTTRANSPARENToutside- Drag works smoothly across monitors
- Snap on release within 12px of work-area edge
- Left-click (no drag) posts panel-toggle message
- Right-click posts context-menu request
- Public API
run,update_percentage - Manual test: bubble visible, draggable, snaps, percentage updates
Success Criteria
- Bubble appears on Windows 10/11 with a Visual Studio-clean cargo build
- Drag works smoothly with no flicker
- Edge snap engages reliably from 12 px
- Bubble survives display reconnection (laptop → external monitor → unplug)
- Percentage text remains crisp on 100%, 125%, 150%, 175% DPI
Risk Assessment
- High — this is novel code with no exact analog in source.
- Risk: ClearType sub-pixel text rendering on a per-pixel-alpha layered window looks bad. Source repo's
window.rssolved this with a black background-pixel hack (alpha = 0x01so it's nearly transparent but still gets ClearType). Apply the same trick for the circle's interior fill region. - Risk: GDI
AngleArcdoesn't anti-alias. Mitigation: either accept aliased v1, or render to a 2x supersampled DIB and downsample. - Risk: Snap math wrong on rotated taskbar or unusual DPI configurations. Mitigation: clamp to monitor work area only, ignore taskbar position.
Security Considerations
- N/A in this phase; bubble does not handle user input beyond mouse position and clicks.
Next Steps
→ Phase 4: expanded panel, settings persistence, polling thread, orchestration