From 526786b902faad4914abf483df61ba3c3d2b3e32 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 23 May 2026 12:09:03 +0700 Subject: [PATCH] feat(bubble): new stadium shape with ring head + tail bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 lite. Replaces the horizontal pill (two stacked progress bars) with a stadium-shaped bubble: a circle "head" on the left showing the 5h percentage as a big glyph surrounded by a stroked progress ring, plus a "tail" extending right with the 7d label, a thin progress bar, and the 7d countdown. The bubble's primary metric (5h window) is now glanceable from across the room — a thick ring sweeping around a big number reads at a much greater distance than two thin horizontal bars. The 7d window remains visible as supporting context. The expanded panel (left-click) still shows both windows in full. Implementation notes: - Hybrid render: tiny-skia (already a Cargo dep for tray badge) paints the AA shape into a Pixmap. The pixmap is copied byte-for-byte into the 32bpp BI_RGB DIB; GDI overlays ClearType text on top; UpdateLayeredWindow blits with per-pixel alpha as before. - Stadium outline: corner_radius = height/2 so point_in_rounded_rect exactly approximates the capsule shape for hit-test. - Pulse animation on ≥95% applies to both the ring sweep (5h) and the tail bar fill (7d) independently. - Codex teal #10A37F and Claude orange #D97757 carry across the ring, the tail bar, and the tray badge sweep via crate::usage_color. Removed (dead after pipeline swap): - per-pixel paint_background / paint_accent_stripe / paint_bars / paint_one_bar / apply_alpha_mask / row_band / rgb_to_dib / blend - BarLayout struct + compute_layout - old paint_text_layer / draw_label / draw_percent / draw_countdown - Breakpoint struct + breakpoint_for_width_logical (font sizes now derive from head_diameter directly) - luminance / use_dark_text_over (text was over bar fills; new tail bar carries no overlaid text) - constants ACCENT_STRIPE_W_LOGICAL, LABEL_PAD_LOGICAL, PERCENT_TEMPLATE Build: cargo build --release clean. Clippy 13 warnings (was 11); the 2 new ones are field-assign-after-Default::default() on tiny-skia Stroke setup, matching the existing pattern in src/tray/badge.rs. Known follow-up: BubbleState.session_text + BubbleConfig.session_text plumbing is now unused (head shows percent only, no 5h countdown on the bubble). Removing it is a multi-file chain through app.rs and panel.rs; deferred. --- src/bubble.rs | 679 ++++++++++++++++++++++++-------------------------- 1 file changed, 323 insertions(+), 356 deletions(-) diff --git a/src/bubble.rs b/src/bubble.rs index 8090dca..50ebcc3 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -1,20 +1,21 @@ -// Floating rounded-rectangle bubble window. +// Floating stadium-shaped bubble window. // // Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE. -// Shape is achieved via per-pixel alpha (alpha=0 outside the rounded rect) and -// confirmed via WM_NCHITTEST returning HTCAPTION inside the rect, HTTRANSPARENT -// outside. The OS handles drag automatically because HTCAPTION inside the -// rect puts the click into the system move loop. +// The shape is a stadium (rounded-rect with corner_radius = height/2). The left +// half is the "head" — a stroked progress ring around the 5h percentage glyph. +// The right half is the "tail" — small "7d" label, thin progress bar, countdown. // -// Layout: two horizontal bars stacked vertically — top = session (5h), bottom = -// weekly (7d) — each followed by right-aligned "X% · Yh Zm" text. The aspect -// ratio is fixed at BUBBLE_ASPECT (3:1) so `size_logical` is interpreted as -// width and the height is derived. +// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc) +// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB; +// GDI then overlays ClearType text on top; UpdateLayeredWindow blits the result +// to the screen with per-pixel alpha. WM_NCHITTEST returns HTCAPTION inside the +// stadium so the OS handles drag for free. use std::collections::HashMap; use std::ffi::c_void; use std::sync::{Mutex, MutexGuard, OnceLock}; +use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform}; use windows::core::PCWSTR; use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; @@ -52,28 +53,6 @@ const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8; const CLASS_NAME: &str = "ClaudeCodeUsageBubble"; const FULLSCREEN_POLL_MS: u32 = 1500; -/// Per-width breakpoint defining bar height, text font size, and row gap in -/// *logical* pixels. Picked so that the smallest bubble is still legible and -/// the largest doesn't waste vertical space. -#[derive(Clone, Copy)] -struct Breakpoint { - bar_h: i32, - font: i32, - row_gap: i32, -} - -fn breakpoint_for_width_logical(w: i32) -> Breakpoint { - if w <= 140 { - Breakpoint { bar_h: 12, font: 11, row_gap: 4 } - } else if w <= 200 { - Breakpoint { bar_h: 16, font: 13, row_gap: 6 } - } else if w <= 280 { - Breakpoint { bar_h: 20, font: 15, row_gap: 8 } - } else { - Breakpoint { bar_h: 24, font: 17, row_gap: 10 } - } -} - /// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200, /// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it /// grows so the wider bars don't look anaemic. @@ -574,7 +553,7 @@ fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT { } fn corner_radius_px(height_px: i32) -> i32 { - (height_px / 3).max(4) + height_px / 2 } fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool { @@ -964,107 +943,285 @@ fn check_fullscreen(bubble_hwnd: HWND) { // ---------- Painting ---------- -const ACCENT_STRIPE_W_LOGICAL: i32 = 4; -const LABEL_PAD_LOGICAL: i32 = 6; // Sized for the widest countdown across all shipped locales. Korean // "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current // worst case; ASCII-only "999d" was too narrow and let CJK text spill // out of the column. Update this when adding a locale with a longer // suffix. const COUNTDOWN_TEMPLATE: &str = "999시간"; -// Percent now lives in its own column between the bar and the countdown so -// the two numeric readouts ("44%" and "3h") sit next to each other for -// quick scanning, and the percent never has to fight the bar's fill colour -// for contrast. -const PERCENT_TEMPLATE: &str = "100%"; -struct BarLayout { - /// Bubble width in pixels. +/// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels. +/// +/// The outline is a stadium (rounded rect with `corner_radius = canvas_h / 2`). +/// The left `head_diameter × canvas_h` square holds the 5h progress ring + big +/// percent glyph. The rest is the tail: 7d label, thin bar, countdown. +struct BubbleLayout { canvas_w: i32, - /// Bubble height in pixels. canvas_h: i32, - /// Corner radius of the rounded rectangle. corner_radius: i32, - /// Accent stripe (left edge) in pixels — Claude orange or Codex neutral. - accent_right: i32, - /// Row label column ("5h" / "7d"). - label_left: i32, - label_right: i32, - /// Percent column ("44%"), rendered on the bubble background. - percent_left: i32, - percent_right: i32, - /// Bar geometry. - bar_left: i32, - bar_right: i32, - bar_h: i32, - /// Right-side countdown text column. - right_text_left: i32, - right_text_right: i32, - /// Vertical positions (top edge of each row's bar). - row1_y: i32, - row2_y: i32, - /// Font size for the main text (percent + countdown). - font_px: i32, - /// Font size for the muted row labels — a notch smaller than `font_px`. - label_font_px: i32, + head_diameter: i32, + ring_cx: f32, + ring_cy: f32, + ring_radius: f32, + ring_stroke_w: f32, + head_label_rect: RECT, + head_pct_rect: RECT, + tail_label_rect: RECT, + tail_bar_rect: RECT, + tail_countdown_rect: RECT, + big_font_px: i32, + small_font_px: i32, + main_font_px: i32, } -fn compute_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BarLayout { - let bp = breakpoint_for_width_logical(size_logical); +fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayout { let width_px = scale_to_dpi(size_logical, dpi); let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi); - let bar_h = scale_to_dpi(bp.bar_h, dpi); - let row_gap = scale_to_dpi(bp.row_gap, dpi); - let pad_x = scale_to_dpi(10, dpi); - let pad_y = ((height_px - bar_h * 2 - row_gap) / 2).max(scale_to_dpi(6, dpi)); - let accent_w = scale_to_dpi(ACCENT_STRIPE_W_LOGICAL, dpi); - let label_pad = scale_to_dpi(LABEL_PAD_LOGICAL, dpi); - let corner_radius = scale_to_dpi((bp.bar_h + bp.row_gap).max(8), dpi).min(height_px / 2); + let head_diameter = height_px; - // Measure the worst-case strings against the real font so the columns are - // exactly wide enough — no more, no less. - let font_px = scale_to_dpi(bp.font, dpi).max(scale_to_dpi(11, dpi)); - let label_font_px = scale_to_dpi(bp.font - 2, dpi).max(scale_to_dpi(9, dpi)); - let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, font_px); - let percent_w = measure_text_w(mem_dc, PERCENT_TEMPLATE, font_px); - let label_w = measure_text_w(mem_dc, "5h", label_font_px) - .max(measure_text_w(mem_dc, "7d", label_font_px)); + let head_pad = scale_to_dpi(6, dpi); + let ring_stroke_w = scale_to_dpi(3, dpi).max(2) as f32; + let ring_cx = (head_diameter as f32) / 2.0; + let ring_cy = (height_px as f32) / 2.0; + // Ring centerline: midway between outer and inner edge, then keep stroke + // inside the head padding. ring_radius is the centerline radius. + let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32); + let ring_radius = ring_outer - ring_stroke_w / 2.0; - // Layout (left → right): - // [accent] [pad] [label] [pad] [bar] [pad] [percent] [pad] [countdown] [pad_x] - let accent_left = 0; - let accent_right = accent_left + accent_w; - let label_left = accent_right + label_pad; - let label_right = label_left + label_w; - let bar_left = label_right + label_pad; + let big_font_px = (head_diameter * 35 / 100).max(scale_to_dpi(12, dpi)); + let small_font_px = ((big_font_px * 45) / 100).max(scale_to_dpi(8, dpi)); + let main_font_px = small_font_px; - let right_text_right = width_px - pad_x; - let right_text_left = (right_text_right - countdown_w).max(bar_left + scale_to_dpi(20, dpi)); - let percent_right = (right_text_left - label_pad).max(bar_left + scale_to_dpi(20, dpi)); - let percent_left = (percent_right - percent_w).max(bar_left + scale_to_dpi(20, dpi)); - let bar_right = (percent_left - label_pad).max(bar_left + scale_to_dpi(20, dpi)); + let head_label_h = small_font_px + scale_to_dpi(2, dpi); + let head_pct_h = big_font_px + scale_to_dpi(2, dpi); + let head_total_h = head_label_h + head_pct_h; + let head_text_top = (height_px - head_total_h) / 2; + let head_label_rect = RECT { + left: scale_to_dpi(4, dpi), + top: head_text_top, + right: head_diameter - scale_to_dpi(4, dpi), + bottom: head_text_top + head_label_h, + }; + let head_pct_rect = RECT { + left: scale_to_dpi(4, dpi), + top: head_text_top + head_label_h, + right: head_diameter - scale_to_dpi(4, dpi), + bottom: head_text_top + head_total_h, + }; - let row1_y = pad_y; - let row2_y = pad_y + bar_h + row_gap; + let tail_left = head_diameter; + let tail_right = width_px - scale_to_dpi(12, dpi); + let pad = scale_to_dpi(6, dpi); - BarLayout { + let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px); + let label_w = measure_text_w(mem_dc, "7d", small_font_px); + + let tail_label_left = tail_left + pad; + let tail_label_right = tail_label_left + label_w; + let tail_countdown_right = tail_right; + let tail_countdown_left = tail_countdown_right - countdown_w; + let tail_bar_left = tail_label_right + pad; + let tail_bar_right = + (tail_countdown_left - pad).max(tail_bar_left + scale_to_dpi(20, dpi)); + let tail_bar_h = scale_to_dpi(6, dpi); + let tail_bar_top = (height_px - tail_bar_h) / 2; + + BubbleLayout { canvas_w: width_px, canvas_h: height_px, - corner_radius, - accent_right, - label_left, - label_right, - percent_left, - percent_right, - bar_left, - bar_right, - bar_h, - right_text_left, - right_text_right, - row1_y, - row2_y, - font_px, - label_font_px, + corner_radius: height_px / 2, + head_diameter, + ring_cx, + ring_cy, + ring_radius, + ring_stroke_w, + head_label_rect, + head_pct_rect, + tail_label_rect: RECT { + left: tail_label_left, + top: 0, + right: tail_label_right, + bottom: height_px, + }, + tail_bar_rect: RECT { + left: tail_bar_left, + top: tail_bar_top, + right: tail_bar_right, + bottom: tail_bar_top + tail_bar_h, + }, + tail_countdown_rect: RECT { + left: tail_countdown_left, + top: 0, + right: tail_countdown_right, + bottom: height_px, + }, + big_font_px, + small_font_px, + main_font_px, + } +} + +/// Render the bubble's shape into a fresh tiny-skia `Pixmap`. The Pixmap is +/// premultiplied RGBA at one byte per channel — the caller copies it into the +/// GDI DIB section, then GDI text is overlaid on top. +fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option { + let mut pixmap = Pixmap::new(layout.canvas_w as u32, layout.canvas_h as u32)?; + pixmap.fill(tiny_skia::Color::TRANSPARENT); + + let bg = if inputs.is_dark { + Color::from_hex("#1F1F1F") + } else { + Color::from_hex("#F3F3F3") + }; + let track = if inputs.is_dark { + Color::from_hex("#3A3A3A") + } else { + Color::from_hex("#D6D6D6") + }; + + // ---- Stadium background ---- + { + let mut paint = Paint::default(); + paint.set_color(rgb_to_skia(bg)); + paint.anti_alias = true; + let r = (layout.canvas_h as f32) / 2.0; + let w = layout.canvas_w as f32; + let h = layout.canvas_h as f32; + + // Two end-cap circles + middle rect. Overlap is fine — same color. + let mut pb = PathBuilder::new(); + pb.push_circle(r, r, r); + pb.push_circle(w - r, r, r); + if let Some(p) = pb.finish() { + pixmap.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None); + } + if let Some(rect) = Rect::from_xywh(r, 0.0, (w - 2.0 * r).max(0.0), h) { + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + } + } + + // ---- Ring (5h) ---- + { + // Track: full circle in muted color. + let mut paint = Paint::default(); + paint.set_color(rgb_to_skia(track)); + paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = layout.ring_stroke_w; + let mut pb = PathBuilder::new(); + pb.push_circle(layout.ring_cx, layout.ring_cy, layout.ring_radius); + if let Some(p) = pb.finish() { + pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None); + } + + // Active sweep arc. + if let Some(pct) = inputs.session_pct { + let sweep = (pct.clamp(0.0, 100.0) / 100.0) as f32; + if sweep > 0.0 { + let mut color = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct); + if pct >= 95.0 { + let t = pulse_triangle(inputs.pulse_phase); + color = brighten(color, t); + } + let mut paint = Paint::default(); + paint.set_color(rgb_to_skia(color)); + paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = layout.ring_stroke_w; + stroke.line_cap = LineCap::Round; + if let Some(path) = + build_arc(layout.ring_cx, layout.ring_cy, layout.ring_radius, sweep) + { + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + } + } + + // ---- Tail bar (7d) ---- + { + let bar_x = layout.tail_bar_rect.left as f32; + let bar_y = layout.tail_bar_rect.top as f32; + let bar_w = (layout.tail_bar_rect.right - layout.tail_bar_rect.left) as f32; + let bar_h = (layout.tail_bar_rect.bottom - layout.tail_bar_rect.top) as f32; + let cap = bar_h * 0.5; + if bar_w > 0.0 && bar_h > 0.0 { + paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, track); + if let Some(pct) = inputs.weekly_pct { + let frac = (pct.clamp(0.0, 100.0) / 100.0) as f32; + let fill_w = bar_w * frac; + if fill_w > 0.0 { + let mut color = + crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct); + if pct >= 95.0 { + let t = pulse_triangle(inputs.pulse_phase); + color = brighten(color, t); + } + let clipped_w = fill_w.min(bar_w); + paint_pill(&mut pixmap, bar_x, bar_y, clipped_w, bar_h, cap, color); + } + } + } + } + + Some(pixmap) +} + +/// Fill a horizontal pill at `(x, y, w, h)` with circular end caps of radius +/// `cap`. Used for both the track and the fill of the tail's 7d bar. +fn paint_pill(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, cap: f32, color: Color) { + let mut paint = Paint::default(); + paint.set_color(rgb_to_skia(color)); + paint.anti_alias = true; + let mut pb = PathBuilder::new(); + pb.push_circle(x + cap, y + h * 0.5, cap); + pb.push_circle(x + w - cap, y + h * 0.5, cap); + if let Some(p) = pb.finish() { + pixmap.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None); + } + if let Some(rect) = Rect::from_xywh(x + cap, y, (w - 2.0 * cap).max(0.0), h) { + pixmap.fill_rect(rect, &paint, Transform::identity(), None); + } +} + +fn rgb_to_skia(c: Color) -> tiny_skia::Color { + tiny_skia::Color::from_rgba8(c.r, c.g, c.b, 0xFF) +} + +/// Build a clockwise arc path starting at 12 o'clock, sweeping `sweep_fraction` +/// of a full turn. Sampled — tiny-skia 0.11 lacks a direct arc primitive. +fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option { + let segments = ((sweep_fraction * 64.0).ceil() as usize).max(1); + let mut pb = PathBuilder::new(); + let start_angle: f32 = -std::f32::consts::FRAC_PI_2; + let total = sweep_fraction * std::f32::consts::TAU; + for i in 0..=segments { + let t = i as f32 / segments as f32; + let a = start_angle + t * total; + let x = cx + a.cos() * radius; + let y = cy + a.sin() * radius; + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + pb.finish() +} + +/// Copy a premultiplied-RGBA `Pixmap` into the 32bpp BI_RGB DIB the bubble +/// uses for `UpdateLayeredWindow`. The DIB stores BGRA bytes (little-endian +/// `0xAARRGGBB` when read as u32); tiny-skia's premultiplied alpha is exactly +/// the format `AC_SRC_ALPHA` expects. +fn copy_pixmap_to_dib(pixmap: &Pixmap, dst: &mut [u32]) { + let src = pixmap.data(); + let pixel_count = (pixmap.width() * pixmap.height()) as usize; + for i in 0..pixel_count { + let r = src[i * 4]; + let g = src[i * 4 + 1]; + let b = src[i * 4 + 2]; + let a = src[i * 4 + 3]; + dst[i] = ((a as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32); } } @@ -1098,14 +1255,6 @@ fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 { } } -/// Pack an `Rgb` for direct write into a 32-bpp `BI_RGB` DIB. The DIB stores -/// bytes B,G,R,X in memory, so a little-endian u32 read is `(b) | (g<<8) | (r<<16)`. -/// Note this is the OPPOSITE byte order from a GDI `COLORREF` (which is -/// `(r) | (g<<8) | (b<<16)`) — don't confuse the two. -fn rgb_to_dib(c: Color) -> u32 { - (c.b as u32) | ((c.g as u32) << 8) | ((c.r as u32) << 16) -} - struct PaintInputs { model: ProviderId, session_pct: Option, @@ -1147,7 +1296,7 @@ fn render(hwnd: HWND) { ReleaseDC(hwnd, screen_dc); return; } - let layout = compute_layout(size_logical, dpi, mem_dc); + let layout = compute_bubble_layout(size_logical, dpi, mem_dc); let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { @@ -1174,17 +1323,14 @@ fn render(hwnd: HWND) { let pixel_count = (layout.canvas_w * layout.canvas_h) as usize; let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); - // Everything outside the rounded rect stays 0 (fully transparent). - pixels.fill(0); - - paint_background(pixels, &layout, &inputs); - paint_accent_stripe(pixels, &layout, inputs.model, inputs.is_dark); - paint_bars(pixels, &layout, &inputs); - paint_text_layer(mem_dc, &layout, &inputs); - - // Final alpha pass: alpha=255 inside the rounded rect, 0 outside. This - // also lifts GDI-drawn text (which leaves alpha=0) into the visible plane. - apply_alpha_mask(pixels, &layout); + // Paint shape via tiny-skia (AA), then copy into the DIB. GDI text + // overlays on top of the resulting bitmap. + if let Some(pixmap) = paint_bubble_pixmap(&layout, &inputs) { + copy_pixmap_to_dib(&pixmap, pixels); + } else { + pixels.fill(0); + } + paint_bubble_text(mem_dc, &layout, &inputs); let mut wr = RECT::default(); let _ = GetWindowRect(hwnd, &mut wr); @@ -1222,104 +1368,6 @@ fn render(hwnd: HWND) { } } -fn paint_background(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) { - let bg = if inputs.is_dark { - Color::from_hex("#1F1F1F") - } else { - Color::from_hex("#F3F3F3") - }; - let bg_packed = rgb_to_dib(bg); - let blush_packed = rgb_to_dib(blend(bg, Color::from_hex("#C45020"), 0.15)); - - let row1_blush = inputs.session_pct.is_some_and(|p| p >= 95.0); - let row2_blush = inputs.weekly_pct.is_some_and(|p| p >= 95.0); - let row1_band = row_band(layout, layout.row1_y); - let row2_band = row_band(layout, layout.row2_y); - - for y in 0..layout.canvas_h { - for x in 0..layout.canvas_w { - if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) { - continue; - } - let in_row1 = row1_blush && y >= row1_band.0 && y < row1_band.1; - let in_row2 = row2_blush && y >= row2_band.0 && y < row2_band.1; - let pixel = if in_row1 || in_row2 { blush_packed } else { bg_packed }; - pixels[(y * layout.canvas_w + x) as usize] = pixel; - } - } -} - -/// Top/bottom y-extent for a row's blush band — slightly taller than the bar -/// so the tint frames the row rather than just sitting under the fill. -fn row_band(layout: &BarLayout, row_top: i32) -> (i32, i32) { - let padding = (layout.bar_h / 4).max(2); - let top = (row_top - padding).max(0); - let bot = (row_top + layout.bar_h + padding).min(layout.canvas_h); - (top, bot) -} - -fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: ProviderId, is_dark: bool) { - let stripe = rgb_to_dib(crate::usage_color::accent_color_for(model, is_dark)); - for y in 0..layout.canvas_h { - for x in 0..layout.accent_right { - if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) { - continue; - } - pixels[(y * layout.canvas_w + x) as usize] = stripe; - } - } -} - -fn paint_bars(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) { - let track = if inputs.is_dark { - Color::from_hex("#3A3A3A") - } else { - Color::from_hex("#D6D6D6") - }; - paint_one_bar(pixels, layout, layout.row1_y, inputs.session_pct, track, inputs); - paint_one_bar(pixels, layout, layout.row2_y, inputs.weekly_pct, track, inputs); -} - -fn paint_one_bar( - pixels: &mut [u32], - layout: &BarLayout, - top: i32, - pct: Option, - track: Color, - inputs: &PaintInputs, -) { - let bar_w = layout.bar_right - layout.bar_left; - if bar_w <= 0 { - return; - } - let track_packed = rgb_to_dib(track); - for y in top..top + layout.bar_h { - for x in layout.bar_left..layout.bar_right { - pixels[(y * layout.canvas_w + x) as usize] = track_packed; - } - } - let Some(p) = pct else { - return; - }; - let fill_w = ((p.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32; - if fill_w <= 0 { - return; - } - let mut accent_rgb = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, p); - if p >= 95.0 { - // Slow brightness triangle: 0.85 → 1.15 over 24 ticks (≈1.9s @ 80ms). - let t = pulse_triangle(inputs.pulse_phase); - accent_rgb = brighten(accent_rgb, t); - } - let accent_packed = rgb_to_dib(accent_rgb); - let end_x = (layout.bar_left + fill_w).min(layout.bar_right); - for y in top..top + layout.bar_h { - for x in layout.bar_left..end_x { - pixels[(y * layout.canvas_w + x) as usize] = accent_packed; - } - } -} - /// Triangle wave in [0, 1] with period 24 ticks. 0 at phase=0,12; 1 at phase=6,18. fn pulse_triangle(phase: u32) -> f64 { let p = (phase % 24) as i32; @@ -1338,20 +1386,9 @@ fn brighten(c: Color, t: f64) -> Color { ) } -fn blend(a: Color, b: Color, t: f64) -> Color { - let t = t.clamp(0.0, 1.0); - Color::new( - ((a.r as f64) * (1.0 - t) + (b.r as f64) * t).round() as u8, - ((a.g as f64) * (1.0 - t) + (b.g as f64) * t).round() as u8, - ((a.b as f64) * (1.0 - t) + (b.b as f64) * t).round() as u8, - ) -} - -/// One pass over the GDI text: row labels (muted) + percent + countdown. -/// The percent column lives between the bar and the countdown so the -/// numeric readouts cluster together for quick scanning, and the percent -/// text always sits on the bubble background — never on the bar fill. -fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) { +/// Paint the new bubble's text overlay via GDI: small "5h" label + big "%" +/// glyph in the head, small "7d" label + countdown on the tail. +fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) { let text_color = if inputs.is_dark { Color::from_hex("#EAEAEA") } else { @@ -1365,36 +1402,59 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) { let font_name = wide_str("Segoe UI"); unsafe { - let main_font = create_font(layout.font_px, &font_name, FW_NORMAL.0 as i32); - let bold_font = create_font(layout.font_px, &font_name, FW_SEMIBOLD.0 as i32); - let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32); + let big_font = create_font(layout.big_font_px, &font_name, FW_SEMIBOLD.0 as i32); + let small_font = create_font(layout.small_font_px, &font_name, FW_NORMAL.0 as i32); + let main_font = create_font(layout.main_font_px, &font_name, FW_NORMAL.0 as i32); SetBkMode(hdc, TRANSPARENT); - // Save the DC's original font so we can restore it before deleting - // ours. DeleteObject silently fails on a still-selected HFONT, - // which would leak the handle on every paint frame. - let prev_font = SelectObject(hdc, label_font); + let prev_font = SelectObject(hdc, small_font); + + // Head: "5h" label (muted, centered horizontally). SetTextColor(hdc, COLORREF(muted_color.into_colorref())); - draw_label(hdc, layout, layout.row1_y, "5h"); - draw_label(hdc, layout, layout.row2_y, "7d"); + draw_text_in_rect(hdc, &layout.head_label_rect, "5h", DT_CENTER); - // Percent in its own column (between bar and countdown). - SelectObject(hdc, bold_font); + // Head: big "X%" glyph centered. + SelectObject(hdc, big_font); SetTextColor(hdc, COLORREF(text_color.into_colorref())); - draw_percent(hdc, layout, layout.row1_y, inputs.session_pct); - draw_percent(hdc, layout, layout.row2_y, inputs.weekly_pct); + let pct_text = match inputs.session_pct { + Some(p) => format!("{:.0}%", p), + None => String::from("—"), + }; + draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER); - // Countdown on the right. + // Tail: "7d" label (muted, left-aligned). + SelectObject(hdc, small_font); + SetTextColor(hdc, COLORREF(muted_color.into_colorref())); + draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT); + + // Tail: countdown (right-aligned). SelectObject(hdc, main_font); SetTextColor(hdc, COLORREF(text_color.into_colorref())); - draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text); - draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text); + if !inputs.weekly_text.is_empty() { + draw_text_in_rect(hdc, &layout.tail_countdown_rect, &inputs.weekly_text, DT_RIGHT); + } - // Restore the original font, then it is safe to delete ours. SelectObject(hdc, prev_font); + let _ = DeleteObject(big_font); + let _ = DeleteObject(small_font); let _ = DeleteObject(main_font); - let _ = DeleteObject(bold_font); - let _ = DeleteObject(label_font); + } +} + +/// Draw `text` into `rect` with the given horizontal alignment flag, vertically +/// centered. The DT_NOCLIP flag preserves ascenders/descenders that would +/// otherwise be clipped by tight rects. +fn draw_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT) { + let mut buf = wide_str(text); + let len_no_nul = buf.len().saturating_sub(1); + let mut r = *rect; + unsafe { + let _ = DrawTextW( + hdc, + &mut buf[..len_no_nul], + &mut r, + halign | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, + ); } } @@ -1419,99 +1479,6 @@ fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT { } } -fn draw_label(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) { - let mut text_w = wide_str(text); - let len_no_nul = text_w.len().saturating_sub(1); - let mut rect = RECT { - left: layout.label_left, - top: row_top - 2, - right: layout.label_right, - bottom: row_top + layout.bar_h + 2, - }; - unsafe { - let _ = DrawTextW( - hdc, - &mut text_w[..len_no_nul], - &mut rect, - DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, - ); - } -} - -fn draw_percent(hdc: HDC, layout: &BarLayout, row_top: i32, pct: Option) { - let Some(p) = pct else { - return; - }; - let text = format!("{:.0}%", p); - let mut text_buf = wide_str(&text); - let len_no_nul = text_buf.len().saturating_sub(1); - let mut rect = RECT { - left: layout.percent_left, - top: row_top, - right: layout.percent_right, - bottom: row_top + layout.bar_h, - }; - unsafe { - let _ = DrawTextW( - hdc, - &mut text_buf[..len_no_nul], - &mut rect, - DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, - ); - } -} - -fn draw_countdown(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) { - if text.is_empty() { - return; - } - // Left-align so the countdown sits right next to the bar with only the - // `label_pad` gap. Right-aligning to the bubble's far edge left a visible - // float between bar end and number. - let mut text_w = wide_str(text); - let len_no_nul = text_w.len().saturating_sub(1); - let mut rect = RECT { - left: layout.right_text_left, - top: row_top - 2, - right: layout.right_text_right, - bottom: row_top + layout.bar_h + 2, - }; - unsafe { - let _ = DrawTextW( - hdc, - &mut text_w[..len_no_nul], - &mut rect, - DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP | DT_END_ELLIPSIS, - ); - } -} - -fn apply_alpha_mask(pixels: &mut [u32], layout: &BarLayout) { - for y in 0..layout.canvas_h { - for x in 0..layout.canvas_w { - let idx = (y * layout.canvas_w + x) as usize; - if point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) { - pixels[idx] |= 0xFF00_0000; - } else { - pixels[idx] = 0; - } - } - } -} - -/// Relative luminance of an sRGB color in 0..255 space. Cheap approximation -/// of the Rec. 709 coefficients — good enough for "should the text on this -/// pixel be white or black?" decisions. -fn luminance(c: Color) -> u32 { - (c.r as u32 * 299 + c.g as u32 * 587 + c.b as u32 * 114) / 1000 -} - -/// Returns true when `c` is light enough that black text is more readable -/// than white text on top of it. -fn use_dark_text_over(c: Color) -> bool { - luminance(c) >= 150 -} - // ---------- Helpers ---------- fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i32) {