diff --git a/src/app.rs b/src/app.rs index 12769bf..3bf0b4b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -502,8 +502,15 @@ fn propagate_to_ui() { let entry = snap.snapshots.get(&id); let session_pct = entry.map(|s| s.windows.primary.utilization); let weekly_pct = entry.map(|s| s.windows.secondary.utilization); - let session_text = entry.map(|s| s.primary_text.clone()).unwrap_or_default(); - let weekly_text = entry.map(|s| s.secondary_text.clone()).unwrap_or_default(); + // The bubble paints the percent inline inside the bar fill, so it + // only needs the countdown string on the right. The panel still + // shows the combined "X% · Yh" string via `primary_text`. + let session_text = entry + .map(|s| i18n::format_countdown(s.windows.primary.resets_at, &snap.i18n_strings)) + .unwrap_or_default(); + let weekly_text = entry + .map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings)) + .unwrap_or_default(); bubble::update_data( hwnd.to_hwnd(), session_pct, diff --git a/src/bubble.rs b/src/bubble.rs index cd4ae95..bf33f4b 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -20,27 +20,73 @@ use windows::Win32::Foundation::*; use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}; use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Shell::ExtractIconExW; +use windows::Win32::UI::Shell::{ + ExtractIconExW, SHAppBarMessage, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETTASKBARPOS, + APPBARDATA, +}; use windows::Win32::UI::WindowsAndMessaging::*; use crate::os::{to_utf16_nul as wide_str, Rgb as Color}; const TIMER_FULLSCREEN_CHECK: usize = 5; +const TIMER_PULSE: usize = 6; +const PULSE_INTERVAL_MS: u32 = 80; use crate::usage::ProviderId; type TrayIconKind = ProviderId; // ---------- Public types & API ---------- -// Width clamps in logical pixels (height = width / BUBBLE_ASPECT). -pub const MIN_BUBBLE_SIZE: i32 = 120; +// Width clamps in logical pixels. Height is derived per width (see +// `bubble_height_logical`) — aspect tapers from 3:1 at the small end toward +// 2.6:1 at the large end so the bars look proportionally chunkier as the +// bubble grows. +pub const MIN_BUBBLE_SIZE: i32 = 140; pub const MAX_BUBBLE_SIZE: i32 = 360; -pub const DEFAULT_BUBBLE_SIZE: i32 = 180; +pub const DEFAULT_BUBBLE_SIZE: i32 = 200; const RESIZE_STEP: i32 = 20; -const BUBBLE_ASPECT: i32 = 3; // width : height = 3 : 1 const SNAP_ZONE_LOGICAL: i32 = 12; +const CORNER_SNAP_ZONE_LOGICAL: i32 = 32; +const CORNER_INSET_LOGICAL: i32 = 12; +const TASKBAR_GAP_LOGICAL: i32 = 4; +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. +fn aspect_at_width(w_logical: i32) -> (i32, i32) { + if w_logical <= 200 { + (3, 1) + } else if w_logical <= 280 { + (14, 5) // 2.8 : 1 + } else { + (13, 5) // 2.6 : 1 + } +} + pub struct BubbleConfig { pub model: TrayIconKind, pub size_logical: i32, @@ -53,7 +99,8 @@ pub struct BubbleConfig { } fn bubble_height_logical(width_logical: i32) -> i32 { - (width_logical / BUBBLE_ASPECT).max(20) + let (num, den) = aspect_at_width(width_logical); + ((width_logical * den) / num).max(20) } /// Register the bubble window class. Idempotent; safe to call before the first @@ -165,6 +212,8 @@ pub fn create(config: BubbleConfig) -> HWND { drag_start_pos: None, hidden_by_fullscreen: false, user_hidden: false, + pulse_phase: 0, + pulse_timer_armed: false, }, ); @@ -181,6 +230,7 @@ pub fn create(config: BubbleConfig) -> HWND { pub fn destroy(hwnd: HWND) { unsafe { let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK); + let _ = KillTimer(hwnd, TIMER_PULSE); let _ = DestroyWindow(hwnd); } } @@ -202,9 +252,40 @@ pub fn update_data( b.weekly_pct = weekly_pct; b.weekly_text = weekly_text; } + sync_pulse_timer(hwnd); render(hwnd); } +fn any_pct_in_alarm(b: &BubbleState) -> bool { + b.session_pct.is_some_and(|p| p >= 95.0) || b.weekly_pct.is_some_and(|p| p >= 95.0) +} + +fn sync_pulse_timer(hwnd: HWND) { + let (should_be_armed, currently_armed) = { + let bubbles = lock_bubbles(); + let Some(b) = bubbles.get(&(hwnd.0 as isize)) else { + return; + }; + (any_pct_in_alarm(b), b.pulse_timer_armed) + }; + if should_be_armed == currently_armed { + return; + } + unsafe { + if should_be_armed { + SetTimer(hwnd, TIMER_PULSE, PULSE_INTERVAL_MS, None); + } else { + let _ = KillTimer(hwnd, TIMER_PULSE); + } + } + if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { + b.pulse_timer_armed = should_be_armed; + if !should_be_armed { + b.pulse_phase = 0; + } + } +} + pub fn update_dark_mode(hwnd: HWND, is_dark: bool) { { let mut bubbles = lock_bubbles(); @@ -266,6 +347,11 @@ struct BubbleState { drag_start_pos: Option<(i32, i32)>, hidden_by_fullscreen: bool, user_hidden: bool, + /// Frame counter for the ≥95% pulse animation. Increments on each + /// TIMER_PULSE tick when at least one bar is in the alarm band. + pulse_phase: u32, + /// Whether TIMER_PULSE is currently armed for this bubble. + pulse_timer_armed: bool, } fn bubbles() -> &'static Mutex> { @@ -367,8 +453,15 @@ unsafe extern "system" fn wnd_proc( LRESULT(0) } WM_TIMER => { - if wparam.0 == TIMER_FULLSCREEN_CHECK { - check_fullscreen(hwnd); + match wparam.0 { + w if w == TIMER_FULLSCREEN_CHECK => check_fullscreen(hwnd), + w if w == TIMER_PULSE => { + if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { + b.pulse_phase = b.pulse_phase.wrapping_add(1); + } + render(hwnd); + } + _ => {} } LRESULT(0) } @@ -376,6 +469,13 @@ unsafe extern "system" fn wnd_proc( crate::app::on_menu_command(wparam.0 as u32, hwnd); LRESULT(0) } + WM_SETTINGCHANGE => { + // Taskbar move / auto-hide toggle / DPI change all post this. + // Just re-clamp into the new work area so the bubble can't end up + // hidden behind the new taskbar position. + clamp_into_work_area(hwnd); + LRESULT(0) + } WM_DESTROY => { lock_bubbles().remove(&(hwnd.0 as isize)); LRESULT(0) @@ -479,7 +579,12 @@ fn snap_to_edge(hwnd: HWND) { .get(&(hwnd.0 as isize)) .map(|b| b.dpi) .unwrap_or(96); - let snap_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi); + let edge_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi); + let corner_zone = scale_to_dpi(CORNER_SNAP_ZONE_LOGICAL, dpi); + let corner_inset = scale_to_dpi(CORNER_INSET_LOGICAL, dpi); + let taskbar_gap = scale_to_dpi(TASKBAR_GAP_LOGICAL, dpi); + let peer_tolerance = scale_to_dpi(PEER_ALIGN_TOLERANCE_LOGICAL, dpi); + let mut r = RECT::default(); let monitor; unsafe { @@ -506,20 +611,27 @@ fn snap_to_edge(hwnd: HWND) { let mut nx = r.left; let mut ny = r.top; - if (nx - wa.left).abs() < snap_zone { - nx = wa.left; - } else if (wa.right - (nx + w)).abs() < snap_zone { - nx = wa.right - w; - } - if (ny - wa.top).abs() < snap_zone { - ny = wa.top; - } else if (wa.bottom - (ny + h)).abs() < snap_zone { - ny = wa.bottom - h; + // 1. Corner snap — if the bubble's nearest-corner distance is under the + // 32-px corner zone, slam it into the corner with the 12-px inset. + let snapped_to_corner = try_corner_snap(&mut nx, &mut ny, &wa, w, h, corner_zone, corner_inset); + + if !snapped_to_corner { + // 2. Edge snap (existing behavior) — also handles taskbar-adjacency + // when the taskbar steals from the work area on the same edge. + let taskbar = read_taskbar(); + snap_to_work_area_edges(&mut nx, &mut ny, &wa, w, h, edge_zone); + if let Some(tb) = taskbar { + snap_alongside_taskbar(&mut nx, &mut ny, &tb, &wa, w, h, edge_zone, taskbar_gap); + } + + // 3. Peer vertical alignment — when the other bubble is within ±8 px + // on Y, snap the dragged bubble to share its baseline. + align_with_peer(hwnd, &mut ny, peer_tolerance); } // Clamp into the work area in any case (so the bubble can't be lost off-screen). - nx = nx.clamp(wa.left, wa.right - w); - ny = ny.clamp(wa.top, wa.bottom - h); + nx = nx.clamp(wa.left, (wa.right - w).max(wa.left)); + ny = ny.clamp(wa.top, (wa.bottom - h).max(wa.top)); if nx != r.left || ny != r.top { unsafe { @@ -536,6 +648,175 @@ fn snap_to_edge(hwnd: HWND) { } } +fn try_corner_snap( + nx: &mut i32, + ny: &mut i32, + wa: &RECT, + w: i32, + h: i32, + zone: i32, + inset: i32, +) -> bool { + // Distance from each work-area corner to the bubble's nearest corner. + let tl = (*nx - wa.left).abs() + (*ny - wa.top).abs(); + let tr = (wa.right - (*nx + w)).abs() + (*ny - wa.top).abs(); + let bl = (*nx - wa.left).abs() + (wa.bottom - (*ny + h)).abs(); + let br = (wa.right - (*nx + w)).abs() + (wa.bottom - (*ny + h)).abs(); + let min = tl.min(tr).min(bl).min(br); + if min > zone * 2 { + return false; + } + if min == tl { + *nx = wa.left + inset; + *ny = wa.top + inset; + } else if min == tr { + *nx = wa.right - inset - w; + *ny = wa.top + inset; + } else if min == bl { + *nx = wa.left + inset; + *ny = wa.bottom - inset - h; + } else { + *nx = wa.right - inset - w; + *ny = wa.bottom - inset - h; + } + true +} + +fn snap_to_work_area_edges(nx: &mut i32, ny: &mut i32, wa: &RECT, w: i32, h: i32, zone: i32) { + if (*nx - wa.left).abs() < zone { + *nx = wa.left; + } else if (wa.right - (*nx + w)).abs() < zone { + *nx = wa.right - w; + } + if (*ny - wa.top).abs() < zone { + *ny = wa.top; + } else if (wa.bottom - (*ny + h)).abs() < zone { + *ny = wa.bottom - h; + } +} + +struct Taskbar { + rect: RECT, + edge: u32, +} + +fn read_taskbar() -> Option { + let mut abd = APPBARDATA { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let res = unsafe { SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd) }; + if res == 0 { + return None; + } + Some(Taskbar { + rect: abd.rc, + edge: abd.uEdge, + }) +} + +fn snap_alongside_taskbar( + nx: &mut i32, + ny: &mut i32, + tb: &Taskbar, + wa: &RECT, + w: i32, + h: i32, + zone: i32, + gap: i32, +) { + // Only snap on the taskbar's docked edge. The bubble docks against the + // inner face of the taskbar with a 4-px gap so it visually leans on it. + match tb.edge { + e if e == ABE_BOTTOM => { + let target = tb.rect.top - gap - h; + if (*ny - target).abs() < zone { + *ny = target.max(wa.top); + } + } + e if e == ABE_TOP => { + let target = tb.rect.bottom + gap; + if (*ny - target).abs() < zone { + *ny = target.min(wa.bottom - h); + } + } + e if e == ABE_LEFT => { + let target = tb.rect.right + gap; + if (*nx - target).abs() < zone { + *nx = target.min(wa.right - w); + } + } + e if e == ABE_RIGHT => { + let target = tb.rect.left - gap - w; + if (*nx - target).abs() < zone { + *nx = target.max(wa.left); + } + } + _ => {} + } +} + +fn clamp_into_work_area(hwnd: HWND) { + let mut r = RECT::default(); + let monitor; + unsafe { + if GetWindowRect(hwnd, &mut r).is_err() { + return; + } + monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + } + if monitor.is_invalid() { + return; + } + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { + if !GetMonitorInfoW(monitor, &mut info).as_bool() { + return; + } + } + let wa = info.rcWork; + let w = r.right - r.left; + let h = r.bottom - r.top; + let nx = r.left.clamp(wa.left, (wa.right - w).max(wa.left)); + let ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top)); + if nx != r.left || ny != r.top { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND::default(), + nx, + ny, + 0, + 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + ); + } + } +} + +fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) { + let bubbles = lock_bubbles(); + for (id, _) in bubbles.iter() { + if *id == this_hwnd.0 as isize { + continue; + } + let peer_hwnd = HWND(*id as *mut c_void); + let mut pr = RECT::default(); + unsafe { + if GetWindowRect(peer_hwnd, &mut pr).is_err() { + continue; + } + } + if (*ny - pr.top).abs() <= tolerance { + *ny = pr.top; + return; + } + } +} + // ---------- Fullscreen detection ---------- fn check_fullscreen(bubble_hwnd: HWND) { @@ -594,41 +875,116 @@ fn check_fullscreen(bubble_hwnd: HWND) { // ---------- Painting ---------- +const ACCENT_STRIPE_W_LOGICAL: i32 = 4; +const LABEL_PAD_LOGICAL: i32 = 6; +const COUNTDOWN_TEMPLATE: &str = "100% \u{00b7} 23h"; + struct BarLayout { + /// Bubble width in pixels. + 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 green. + accent_right: i32, + /// Label column ("5h" / "7d"). + label_left: i32, + label_right: i32, + /// Bar geometry. bar_left: i32, bar_right: i32, bar_h: i32, + /// Right-side countdown text column. right_text_left: i32, - right_text_w: 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 (countdown + inline percent). + font_px: i32, + /// Font size for the muted row labels — a notch smaller than `font_px`. + label_font_px: i32, } -fn compute_layout(width_px: i32, height_px: i32) -> BarLayout { - let pad_x = (height_px / 6).max(3); - // Bar height is capped — the previous formula filled all the vertical space - // and produced a too-tall font that overflowed the right-text column. Cap - // at ~1/4 of the bubble height so the rows have visible padding around them. - let bar_h = (height_px / 4).clamp(6, 18); - let row_gap = (bar_h / 2).max(3); - let pad_y = ((height_px - bar_h * 2 - row_gap) / 2).max(2); - // Right-side text needs ~6× the bar height to fit "100% · 23h" comfortably - // at the font size derived below (bar_h * 0.75). Floor in px for the - // smallest legible width. - let right_text_w = (bar_h * 6).max(56); - let bar_left = pad_x; - let bar_right = (width_px - pad_x - right_text_w - bar_h / 2).max(bar_left + 8); - let right_text_left = bar_right + (bar_h / 2).max(2); +fn compute_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BarLayout { + let bp = breakpoint_for_width_logical(size_logical); + 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); + + // 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 label_w = measure_text_w(mem_dc, "5h", label_font_px) + .max(measure_text_w(mem_dc, "7d", label_font_px)); + + 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 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 bar_right = (right_text_left - label_pad).max(bar_left + scale_to_dpi(20, dpi)); + let row1_y = pad_y; let row2_y = pad_y + bar_h + row_gap; + BarLayout { + canvas_w: width_px, + canvas_h: height_px, + corner_radius, + accent_right, + label_left, + label_right, bar_left, bar_right, bar_h, right_text_left, - right_text_w, + right_text_right, row1_y, row2_y, + font_px, + label_font_px, + } +} + +fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 { + use windows::Win32::Foundation::SIZE; + let font_name = wide_str("Segoe UI"); + let mut w: Vec = text.encode_utf16().collect(); + unsafe { + let font = CreateFontW( + -font_height_px, + 0, + 0, + 0, + FW_NORMAL.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_DEFAULT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + CLEARTYPE_QUALITY.0 as u32, + (FF_SWISS.0 | DEFAULT_PITCH.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ); + let old = SelectObject(hdc, font); + let mut size = SIZE::default(); + let _ = GetTextExtentPoint32W(hdc, &mut w, &mut size); + SelectObject(hdc, old); + let _ = DeleteObject(font); + size.cx } } @@ -640,8 +996,18 @@ fn rgb_to_dib(c: Color) -> u32 { (c.b as u32) | ((c.g as u32) << 8) | ((c.r as u32) << 16) } +struct PaintInputs { + model: TrayIconKind, + session_pct: Option, + session_text: String, + weekly_pct: Option, + weekly_text: String, + is_dark: bool, + pulse_phase: u32, +} + fn render(hwnd: HWND) { - let (size_logical, dpi, session_pct, session_text, weekly_pct, weekly_text, is_dark) = { + let (size_logical, dpi, inputs) = { let bubbles = lock_bubbles(); let Some(b) = bubbles.get(&(hwnd.0 as isize)) else { return; @@ -649,26 +1015,28 @@ fn render(hwnd: HWND) { ( b.size_logical, b.dpi, - b.session_pct, - b.session_text.clone(), - b.weekly_pct, - b.weekly_text.clone(), - b.is_dark, + PaintInputs { + model: b.model, + session_pct: b.session_pct, + session_text: b.session_text.clone(), + weekly_pct: b.weekly_pct, + weekly_text: b.weekly_text.clone(), + is_dark: b.is_dark, + pulse_phase: b.pulse_phase, + }, ) }; - let width_px = scale_to_dpi(size_logical, dpi); - let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi); - let radius = corner_radius_px(height_px); - let layout = compute_layout(width_px, height_px); unsafe { let screen_dc = GetDC(hwnd); let mem_dc = CreateCompatibleDC(screen_dc); + let layout = compute_layout(size_logical, dpi, mem_dc); + let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { biSize: std::mem::size_of::() as u32, - biWidth: width_px, - biHeight: -height_px, + biWidth: layout.canvas_w, + biHeight: -layout.canvas_h, biPlanes: 1, biBitCount: 32, biCompression: 0, @@ -686,25 +1054,20 @@ fn render(hwnd: HWND) { } let old_bmp = SelectObject(mem_dc, dib); - let pixel_count = (width_px * height_px) as usize; + 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, width_px, height_px, radius, is_dark); - paint_bars(pixels, width_px, &layout, session_pct, weekly_pct, is_dark); - paint_bar_texts( - mem_dc, - &layout, - &session_text, - &weekly_text, - is_dark, - ); + paint_background(pixels, &layout, &inputs); + paint_accent_stripe(pixels, &layout, inputs.model); + 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, width_px, height_px, radius); + apply_alpha_mask(pixels, &layout); let mut wr = RECT::default(); let _ = GetWindowRect(hwnd, &mut wr); @@ -714,8 +1077,8 @@ fn render(hwnd: HWND) { }; let pt_src = POINT { x: 0, y: 0 }; let sz = SIZE { - cx: width_px, - cy: height_px, + cx: layout.canvas_w, + cy: layout.canvas_h, }; let blend = BLENDFUNCTION { BlendOp: 0, @@ -742,55 +1105,87 @@ fn render(hwnd: HWND) { } } -fn paint_background(pixels: &mut [u32], w: i32, h: i32, radius: i32, is_dark: bool) { - let bg = if is_dark { +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); - for y in 0..h { - for x in 0..w { - if point_in_rounded_rect(x, y, w, h, radius) { - pixels[(y * w + x) as usize] = bg_packed; + 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; } } } -fn paint_bars( - pixels: &mut [u32], - width_px: i32, - layout: &BarLayout, - session_pct: Option, - weekly_pct: Option, - is_dark: bool, -) { - let track = if is_dark { - rgb_to_dib(Color::from_hex("#3A3A3A")) - } else { - rgb_to_dib(Color::from_hex("#D6D6D6")) - }; - paint_bar(pixels, width_px, layout, layout.row1_y, session_pct, track); - paint_bar(pixels, width_px, layout, layout.row2_y, weekly_pct, track); +/// 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_bar( +fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: TrayIconKind) { + let stripe = rgb_to_dib(accent_color_for(model)); + 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 accent_color_for(model: TrayIconKind) -> Color { + match model { + ProviderId::Claude => Color::from_hex("#D97757"), + ProviderId::ChatGpt => Color::from_hex("#10A37F"), + } +} + +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.pulse_phase); + paint_one_bar(pixels, layout, layout.row2_y, inputs.weekly_pct, track, inputs.pulse_phase); +} + +fn paint_one_bar( pixels: &mut [u32], - width_px: i32, layout: &BarLayout, top: i32, pct: Option, - track: u32, + track: Color, + pulse_phase: u32, ) { let bar_w = layout.bar_right - layout.bar_left; if bar_w <= 0 { return; } - // Track first. + 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 * width_px + x) as usize] = track; + pixels[(y * layout.canvas_w + x) as usize] = track_packed; } } let Some(p) = pct else { @@ -800,38 +1195,101 @@ fn paint_bar( if fill_w <= 0 { return; } - let accent = rgb_to_dib(ring_color_for_percent(p)); + let mut accent_rgb = ring_color_for_percent(p); + if p >= 95.0 { + // Slow brightness triangle: 0.85 → 1.15 over 24 ticks (≈1.9s @ 80ms). + let t = pulse_triangle(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 * width_px + x) as usize] = accent; + pixels[(y * layout.canvas_w + x) as usize] = accent_packed; } } } -fn paint_bar_texts( - hdc: HDC, - layout: &BarLayout, - session_text: &str, - weekly_text: &str, - is_dark: bool, -) { - let text_color = if is_dark { +/// 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; + let dist = (p - 12).abs(); // 0..12 + 1.0 - (dist as f64 / 12.0) +} + +/// Linearly brighten `c` toward white by `t` in [0, 1]. +fn brighten(c: Color, t: f64) -> Color { + // Map t to a smaller brightness delta — the pulse should be a subtle nudge. + let t = t.clamp(0.0, 1.0) * 0.30; + Color::new( + ((c.r as f64) + (255.0 - c.r as f64) * t).round() as u8, + ((c.g as f64) + (255.0 - c.g as f64) * t).round() as u8, + ((c.b as f64) + (255.0 - c.b as f64) * t).round() as u8, + ) +} + +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) + inline percent (inside +/// the bar) + countdown (right column). +fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) { + let text_color = if inputs.is_dark { Color::from_hex("#EAEAEA") } else { Color::from_hex("#1F1F1F") }; - // Match font height to ~75% of the bar height — leaves room above/below - // for descenders and keeps "100% · 23h" within `right_text_w`. - let font_size = (layout.bar_h as f64 * 0.75).round() as i32; + let muted_color = if inputs.is_dark { + Color::from_hex("#888888") + } else { + Color::from_hex("#6E6E6E") + }; + let font_name = wide_str("Segoe UI"); unsafe { - let font = CreateFontW( - -font_size.max(8), + 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); + SetBkMode(hdc, TRANSPARENT); + + // Row labels in the left column. + SelectObject(hdc, label_font); + SetTextColor(hdc, COLORREF(muted_color.into_colorref())); + draw_label(hdc, layout, layout.row1_y, "5h"); + draw_label(hdc, layout, layout.row2_y, "7d"); + + // Inline percent: drawn over the bar, contrast picked from the pixel + // under the text (fill if covered, track otherwise). + SelectObject(hdc, bold_font); + draw_inline_percent(hdc, layout, layout.row1_y, inputs.session_pct, inputs.is_dark); + draw_inline_percent(hdc, layout, layout.row2_y, inputs.weekly_pct, inputs.is_dark); + + // Countdown on the right. + 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); + + let _ = DeleteObject(main_font); + let _ = DeleteObject(bold_font); + let _ = DeleteObject(label_font); + } +} + +fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT { + unsafe { + CreateFontW( + -height_px, 0, 0, 0, - FW_NORMAL.0 as i32, + weight, 0, 0, 0, @@ -840,31 +1298,18 @@ fn paint_bar_texts( CLIP_DEFAULT_PRECIS.0 as u32, CLEARTYPE_QUALITY.0 as u32, (FF_SWISS.0 | DEFAULT_PITCH.0) as u32, - PCWSTR::from_raw(font_name.as_ptr()), - ); - let old_font = SelectObject(hdc, font); - SetTextColor(hdc, COLORREF(text_color.into_colorref())); - SetBkMode(hdc, TRANSPARENT); - - draw_right_text(hdc, layout, layout.row1_y, session_text); - draw_right_text(hdc, layout, layout.row2_y, weekly_text); - - SelectObject(hdc, old_font); - let _ = DeleteObject(font); + PCWSTR::from_raw(name_w.as_ptr()), + ) } } -fn draw_right_text(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) { - if text.is_empty() { - return; - } +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); - // Vertically center within the bar row. let mut rect = RECT { - left: layout.right_text_left, + left: layout.label_left, top: row_top - 2, - right: layout.right_text_left + layout.right_text_w, + right: layout.label_right, bottom: row_top + layout.bar_h + 2, }; unsafe { @@ -872,16 +1317,91 @@ fn draw_right_text(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) { hdc, &mut text_w[..len_no_nul], &mut rect, - DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP | DT_END_ELLIPSIS, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, ); } } -fn apply_alpha_mask(pixels: &mut [u32], w: i32, h: i32, radius: i32) { - for y in 0..h { - for x in 0..w { - let idx = (y * w + x) as usize; - if point_in_rounded_rect(x, y, w, h, radius) { +fn draw_inline_percent( + hdc: HDC, + layout: &BarLayout, + row_top: i32, + pct: Option, + is_dark: bool, +) { + let Some(p) = pct else { + return; + }; + let text = format!("{:.0}%", p); + let mut text_w = wide_str(&text); + let len_no_nul = text_w.len().saturating_sub(1); + + // Pick the color: when the fill covers the right edge of the bar, the + // percent sits on the fill — contrast against the fill's color. Otherwise + // it sits on the track — contrast against the track. + let bar_w = layout.bar_right - layout.bar_left; + let fill_w = ((p.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32; + let fill_color = ring_color_for_percent(p); + let track_color = if is_dark { + Color::from_hex("#3A3A3A") + } else { + Color::from_hex("#D6D6D6") + }; + // The percent sits at the right edge of the bar with a small inset; if + // fill_w covers most of the bar it lies on the fill, else on the track. + let on_fill = fill_w > bar_w * 2 / 3; + let underlying = if on_fill { fill_color } else { track_color }; + let fg = if use_dark_text_over(underlying) { + Color::from_hex("#101010") + } else { + Color::from_hex("#F5F5F5") + }; + + let inset = (layout.bar_h / 3).max(3); + let mut rect = RECT { + left: layout.bar_left, + top: row_top - 2, + right: layout.bar_right - inset, + bottom: row_top + layout.bar_h + 2, + }; + unsafe { + SetTextColor(hdc, COLORREF(fg.into_colorref())); + let _ = DrawTextW( + hdc, + &mut text_w[..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; + } + 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_RIGHT | 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; @@ -890,35 +1410,37 @@ fn apply_alpha_mask(pixels: &mut [u32], w: i32, h: i32, radius: i32) { } } +/// Discrete 4-band color "cliff" — sharper steps than a smooth gradient so +/// the visual difference between 75% and 85% is unmistakable at the bubble's +/// 60-90 px bar width. +/// +/// - <60% → Claude orange (#D97757) +/// - 60–80% → amber (#E0A040) +/// - 80–95% → red (#C45020) +/// - ≥95% → deep red (#A01818) — paired with pulse animation in render pub fn ring_color_for_percent(percent: f64) -> Color { - if percent <= 50.0 { - return Color::from_hex("#D97757"); + if percent < 60.0 { + Color::from_hex("#D97757") + } else if percent < 80.0 { + Color::from_hex("#E0A040") + } else if percent < 95.0 { + Color::from_hex("#C45020") + } else { + Color::from_hex("#A01818") } - let stops: [(f64, Color); 5] = [ - (50.0, Color::from_hex("#D97757")), - (70.0, Color::from_hex("#D08540")), - (85.0, Color::from_hex("#CC8C20")), - (95.0, Color::from_hex("#C45020")), - (100.0, Color::from_hex("#B82020")), - ]; - for pair in stops.windows(2) { - let (sp, sc) = pair[0]; - let (ep, ec) = pair[1]; - if percent <= ep { - let span = (ep - sp).max(f64::EPSILON); - let t = ((percent - sp) / span).clamp(0.0, 1.0); - return Color::new( - lerp_u8(sc.r, ec.r, t), - lerp_u8(sc.g, ec.g, t), - lerp_u8(sc.b, ec.b, t), - ); - } - } - Color::from_hex("#B82020") } -fn lerp_u8(a: u8, b: u8, t: f64) -> u8 { - (a as f64 + (b as f64 - a as f64) * t).round() as u8 +/// 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 ---------- diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 918eed7..5b25574 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -198,7 +198,9 @@ pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> } } -fn format_countdown(resets_at: Option, strings: &LocaleStrings) -> String { +/// Countdown only — used by the bubble, which renders the percent inside the +/// bar fill and only needs the time-to-reset on the right. +pub fn format_countdown(resets_at: Option, strings: &LocaleStrings) -> String { let Some(reset) = resets_at else { return String::new(); }; diff --git a/src/panel.rs b/src/panel.rs index 4ec9388..5f6bbfe 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -276,6 +276,23 @@ fn paint(hwnd: HWND, hdc: HDC) { FillRect(hdc, &rc, bg_brush); let _ = DeleteObject(bg_brush); + // 4-px accent stripe matching the bubble — same provider color so the + // identity carries across both surfaces. + let stripe_color = match data.model { + ProviderId::Claude => Color::from_hex("#D97757"), + ProviderId::ChatGpt => Color::from_hex("#10A37F"), + }; + let stripe_w = scaled(4); + let stripe_rect = RECT { + left: 0, + top: 0, + right: stripe_w, + bottom: rc.bottom, + }; + let stripe_brush = CreateSolidBrush(COLORREF(stripe_color.into_colorref())); + FillRect(hdc, &stripe_rect, stripe_brush); + let _ = DeleteObject(stripe_brush); + // Header row: model label let header = match data.model { ProviderId::Claude => data.strings.claude_label.clone(),