diff --git a/src/badge.rs b/src/badge.rs new file mode 100644 index 0000000..1340021 --- /dev/null +++ b/src/badge.rs @@ -0,0 +1,228 @@ +//! Anti-aliased tray badge rendering. +//! +//! A filled disk plus a percentage-sweep ring, drawn with tiny-skia. The +//! badge is intentionally text-free: the floating bubble already shows the +//! exact percentage, the tray badge is a coarse colour-and-fill indicator. + +use tiny_skia::{FillRule, LineCap, Paint, Path, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::core::usage_color; +use crate::usage::ProviderId; + +/// Width and height of the badge in pixels. +pub const BADGE_PX: u32 = 32; + +/// Colour of the unused part of the ring. +const TRACK_RGB: [u8; 3] = [0x3a, 0x3a, 0x3a]; + +/// Render the badge for `kind` with `percent` of the ring filled, or only +/// the track when `percent` is `None`. Premultiplied RGBA. +pub fn render_pixmap(kind: ProviderId, percent: Option) -> Pixmap { + let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32x32 pixmap"); + pixmap.fill(tiny_skia::Color::TRANSPARENT); + + let c = BADGE_PX as f32 / 2.0; + let outer = c - 1.0; + let inner = outer * 0.62; + let ring_radius = (inner + outer) / 2.0; + let ring_width = outer - inner - 1.0; + + // Filled inner disk in the provider's base tint. + let mut pb = PathBuilder::new(); + pb.push_circle(c, c, inner); + if let Some(path) = pb.finish() { + pixmap.fill_path( + &path, + &paint(base_color(kind)), + FillRule::Winding, + Transform::identity(), + None, + ); + } + + // Track ring (the unused portion of the quota). + let mut pb = PathBuilder::new(); + pb.push_circle(c, c, ring_radius); + if let Some(path) = pb.finish() { + let stroke = Stroke { + width: ring_width, + ..Stroke::default() + }; + pixmap.stroke_path( + &path, + &paint(TRACK_RGB), + &stroke, + Transform::identity(), + None, + ); + } + + // Active sweep: the used share of the ring in the usage colour. + if let Some(p) = percent { + let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32; + if sweep > 0.0 { + if let Some(path) = build_arc(c, c, ring_radius, sweep) { + let stroke = Stroke { + width: ring_width, + line_cap: LineCap::Round, + ..Stroke::default() + }; + pixmap.stroke_path( + &path, + &paint(usage_rgb(kind, p)), + &stroke, + Transform::identity(), + None, + ); + } + } + } + + pixmap +} + +/// The pixmap as straight (non-premultiplied) RGBA bytes, the format +/// `tauri::image::Image::new_owned` expects. +pub fn demultiplied_rgba(pixmap: &Pixmap) -> Vec { + pixmap + .pixels() + .iter() + .flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) + .collect() +} + +fn paint(rgb: [u8; 3]) -> Paint<'static> { + let mut paint = Paint::default(); + paint.set_color_rgba8(rgb[0], rgb[1], rgb[2], 255); + paint.anti_alias = true; + paint +} + +/// An open polyline arc from 12 o'clock, clockwise. tiny-skia 0.11 has no +/// arc primitive, so the circumference is sampled. +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 start = -std::f32::consts::FRAC_PI_2; + let total = sweep_fraction * std::f32::consts::TAU; + let mut pb = PathBuilder::new(); + for i in 0..=segments { + let a = start + (i as f32 / segments as f32) * total; + let (x, y) = (cx + a.cos() * radius, cy + a.sin() * radius); + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + pb.finish() +} + +fn base_color(kind: ProviderId) -> [u8; 3] { + match kind { + // A dark warm tint reads as Claude without copying its exact accent. + ProviderId::Claude => [0x2a, 0x1f, 0x1c], + // Cool dark slate for Codex. + ProviderId::ChatGpt => [0x1a, 0x1f, 0x26], + // Deep green for OpenCode Go. + ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20], + } +} + +/// Sweep colour. The badge disk is always dark whatever the system theme, +/// so the dark palette keeps the ring readable (Codex stays white). +fn usage_rgb(kind: ProviderId, percent: f64) -> [u8; 3] { + let c = usage_color::bar_fill_color(kind, true, percent); + [c.r, c.g, c.b] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Straight RGB at a pixel, asserting it is fully opaque. + fn opaque_rgb(pixmap: &Pixmap, x: u32, y: u32) -> [u8; 3] { + let p = pixmap.pixel(x, y).expect("pixel in range").demultiply(); + assert_eq!(p.alpha(), 255, "pixel ({x},{y}) is not opaque"); + [p.red(), p.green(), p.blue()] + } + + /// The pixel whose centre lies on the ring radius (12.15) to the right + /// of the centre: x = 16 + 12.15 rounds into pixel 28. + const TRACK_SAMPLE: (u32, u32) = (28, 16); + /// The pixel at 12 o'clock on the ring radius: y = 16 - 12.15 = 3.85. + const TOP_SAMPLE: (u32, u32) = (16, 3); + + #[test] + fn centre_is_the_provider_disk() { + for kind in ProviderId::ALL { + let pixmap = render_pixmap(kind, Some(50.0)); + assert_eq!(opaque_rgb(&pixmap, 16, 16), base_color(kind), "{kind:?}"); + } + } + + #[test] + fn ring_is_track_coloured_without_a_percent() { + let pixmap = render_pixmap(ProviderId::Claude, None); + assert_eq!( + opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1), + TRACK_RGB + ); + assert_eq!(opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB); + // Zero percent draws no arc either. + let zero = render_pixmap(ProviderId::Claude, Some(0.0)); + assert_eq!(opaque_rgb(&zero, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB); + } + + #[test] + fn arc_starts_at_twelve_o_clock_for_any_percent() { + for kind in ProviderId::LIVE_USAGE { + for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] { + let pixmap = render_pixmap(kind, Some(pct)); + assert_eq!( + opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1), + usage_rgb(kind, pct), + "{kind:?} at {pct}%" + ); + } + } + } + + #[test] + fn a_quarter_sweep_leaves_the_left_side_as_track() { + let pixmap = render_pixmap(ProviderId::Claude, Some(25.0)); + // 3 o'clock is the end of the sweep (round cap), 9 o'clock is track. + assert_eq!( + opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1), + usage_rgb(ProviderId::Claude, 25.0) + ); + assert_eq!(opaque_rgb(&pixmap, 3, 16), TRACK_RGB); + } + + #[test] + fn corners_stay_transparent() { + let pixmap = render_pixmap(ProviderId::ChatGpt, Some(100.0)); + assert_eq!(pixmap.pixel(0, 0).expect("pixel").alpha(), 0); + assert_eq!(pixmap.pixel(31, 31).expect("pixel").alpha(), 0); + } + + #[test] + fn demultiply_restores_straight_alpha() { + let mut pixmap = Pixmap::new(2, 1).expect("pixmap"); + // 50% alpha red: premultiplied (128, 0, 0, 128). + pixmap.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 128)); + assert_eq!(pixmap.data()[..4], [128, 0, 0, 128]); + assert_eq!(demultiplied_rgba(&pixmap), [255, 0, 0, 128, 255, 0, 0, 128]); + + let badge = render_pixmap(ProviderId::Claude, Some(42.0)); + let rgba = demultiplied_rgba(&badge); + assert_eq!(rgba.len(), (BADGE_PX * BADGE_PX * 4) as usize); + // Opaque pixels are unchanged; transparent ones stay zero. + assert_eq!(rgba[..4], [0, 0, 0, 0]); + let centre = ((16 * BADGE_PX + 16) * 4) as usize; + assert_eq!(rgba[centre..centre + 3], base_color(ProviderId::Claude)); + assert_eq!(rgba[centre + 3], 255); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7794336..bc3346b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ //! The binary in `src/main.rs` only calls [`run`]. Every module is `pub` so //! that items wired up incrementally do not trip `dead_code`. +pub mod badge; pub mod cli; pub mod commands; pub mod core; diff --git a/src/tray/badge.rs b/src/tray/badge.rs deleted file mode 100644 index caaf869..0000000 --- a/src/tray/badge.rs +++ /dev/null @@ -1,208 +0,0 @@ -// Anti-aliased tray badge rendering. -// -// We draw a filled circle + percentage-sweep ring with tiny-skia, then -// hand the resulting BGRA pixmap to Win32 as an HICON via -// `CreateIconIndirect`. The badge is intentionally text-free — the -// floating bubble already shows the exact percentage; the tray badge -// is just a coarse colour-and-fill indicator. - -use std::ffi::c_void; - -use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform}; -use windows::Win32::Foundation::{BOOL, HWND}; -use windows::Win32::Graphics::Gdi::{ - CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER, - DIB_RGB_COLORS, HBITMAP, -}; -use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO}; - -use crate::usage::ProviderId; - -const BADGE_PX: u32 = 32; - -pub fn render_hicon(kind: ProviderId, percent: Option) -> HICON { - let pixmap = render_pixmap(kind, percent); - pixmap_to_hicon(&pixmap).unwrap_or_default() -} - -fn render_pixmap(kind: ProviderId, percent: Option) -> Pixmap { - let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32×32 pixmap"); - pixmap.fill(tiny_skia::Color::TRANSPARENT); - - let cx = BADGE_PX as f32 / 2.0; - let cy = BADGE_PX as f32 / 2.0; - let outer = (BADGE_PX as f32 / 2.0) - 1.0; - let inner = outer * 0.62; - - // Filled inner disk in the model's base tint. - let base = base_color(kind); - { - let mut paint = Paint::default(); - paint.set_color_rgba8(base[0], base[1], base[2], 255); - paint.anti_alias = true; - let mut pb = PathBuilder::new(); - pb.push_circle(cx, cy, inner); - if let Some(path) = pb.finish() { - pixmap.fill_path( - &path, - &paint, - FillRule::Winding, - Transform::identity(), - None, - ); - } - } - - // Track ring (the unused portion of the quota). - { - let mut paint = Paint::default(); - paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255); - paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = outer - inner - 1.0; - let mut pb = PathBuilder::new(); - pb.push_circle(cx, cy, (inner + outer) / 2.0); - if let Some(path) = pb.finish() { - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - } - - // Active sweep — percentage of ring filled in usage colour. - if let Some(p) = percent { - let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32; - if sweep > 0.0 { - let fill = usage_color(kind, p); - let mut paint = Paint::default(); - paint.set_color_rgba8(fill[0], fill[1], fill[2], 255); - paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = outer - inner - 1.0; - stroke.line_cap = tiny_skia::LineCap::Round; - - let pb_path = build_arc(cx, cy, (inner + outer) / 2.0, sweep); - if let Some(path) = pb_path { - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - } - } - - pixmap -} - -fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option { - // tiny-skia 0.11 lacks a direct arc primitive. Approximate by sampling - // points along the circumference from the 12 o'clock position clockwise. - let segments = (sweep_fraction * 64.0).ceil() as usize; - let segments = segments.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() -} - -fn base_color(kind: ProviderId) -> [u8; 3] { - match kind { - // Warm orange-ish tint reads as "Claude" without copying the - // upstream's exact #D97757; close enough to be familiar. - ProviderId::Claude => [0x2a, 0x1f, 0x1c], - // Cool dark slate for ChatGPT/Codex. - ProviderId::ChatGpt => [0x1a, 0x1f, 0x26], - // Deep green for OpenCode Go. - ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20], - } -} - -/// Sweep-ring fill color for the tray badge. The badge inner disk is always -/// dark regardless of system theme, so we pass `is_dark = true` to keep the -/// ring readable (Codex sweep stays white instead of charcoal). -fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] { - let c = crate::usage_color::bar_fill_color(kind, true, percent); - [c.r, c.g, c.b] -} - -// ---------- Pixmap → HICON ---------- - -fn pixmap_to_hicon(pixmap: &Pixmap) -> Option { - let width = pixmap.width() as i32; - let height = pixmap.height() as i32; - let pixels = pixmap.data(); // tiny-skia premultiplied RGBA bytes - - // Build a 32bpp top-down DIB section for the colour bitmap. - let bmi = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: width, - biHeight: -height, - biPlanes: 1, - biBitCount: 32, - biCompression: 0, // BI_RGB - ..Default::default() - }, - ..Default::default() - }; - - unsafe { - let hdc = GetDC(HWND::default()); - let mut bits: *mut c_void = std::ptr::null_mut(); - let color_bmp = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) - .ok() - .unwrap_or_default(); - ReleaseDC(HWND::default(), hdc); - if color_bmp.is_invalid() || bits.is_null() { - return None; - } - // tiny-skia produces RGBA (premultiplied); GDI's DIB is BGRA. Swap. - let pixel_count = (width * height) as usize; - let dst = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); - for i in 0..pixel_count { - let r = pixels[i * 4]; - let g = pixels[i * 4 + 1]; - let b = pixels[i * 4 + 2]; - let a = pixels[i * 4 + 3]; - dst[i] = (a as u32) << 24 | (r as u32) << 16 | (g as u32) << 8 | (b as u32); - } - - // Monochrome AND mask — opaque pixels marked 0, transparent 1. - let mask_row_stride = ((width + 15) / 16) * 2; // 16-bit aligned per scanline - let mut mask = vec![0u8; (mask_row_stride * height) as usize]; - for y in 0..height { - for x in 0..width { - let idx = (y * width + x) as usize; - let alpha = pixels[idx * 4 + 3]; - if alpha == 0 { - let byte = (y * mask_row_stride + (x / 8)) as usize; - mask[byte] |= 0x80 >> (x % 8); - } - } - } - let mask_bmp: HBITMAP = CreateBitmap(width, height, 1, 1, Some(mask.as_ptr() as *const _)); - if mask_bmp.is_invalid() { - let _ = DeleteObject(color_bmp); - return None; - } - - let info = ICONINFO { - fIcon: BOOL(1), - xHotspot: 0, - yHotspot: 0, - hbmMask: mask_bmp, - hbmColor: color_bmp, - }; - let hicon = CreateIconIndirect(&info).ok(); - - let _ = DeleteObject(color_bmp); - let _ = DeleteObject(mask_bmp); - hicon - } -} diff --git a/src/tray/callback.rs b/src/tray/callback.rs deleted file mode 100644 index 8849d5c..0000000 --- a/src/tray/callback.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Dispatch the `WM_APP_TRAY` notification message. -// -// Shell_NotifyIconW packs the event in the LOWORD of lparam (the -// underlying mouse-event code) and the icon ID in HIWORD. We translate -// to a `TrayAction` and let the app handle it. - -use windows::Win32::Foundation::LPARAM; - -use super::TrayAction; - -const WM_LBUTTONUP: u32 = 0x0202; -const WM_RBUTTONUP: u32 = 0x0205; - -pub fn handle(lparam: LPARAM) -> TrayAction { - let raw = lparam.0 as u32; - let event = raw & 0xFFFF; - match event { - WM_LBUTTONUP => TrayAction::ToggleWidget, - WM_RBUTTONUP => TrayAction::ShowContextMenu, - _ => TrayAction::None, - } -} diff --git a/src/tray/mod.rs b/src/tray/mod.rs deleted file mode 100644 index 08eb81b..0000000 --- a/src/tray/mod.rs +++ /dev/null @@ -1,143 +0,0 @@ -// Tray-area icon management — stateless module-level API. -// -// Each enabled provider gets one notification-area icon. `sync` reconciles -// the set of registered icons with the supplied desired list, issuing -// `NIM_ADD`, `NIM_MODIFY`, or `NIM_DELETE` per icon. We track registration -// state in a private mutex so callers (the app orchestrator) don't have -// to thread a `Manager` through their snapshot/clone pipelines. - -use std::collections::HashSet; -use std::sync::{Mutex, OnceLock}; - -use windows::Win32::Foundation::HWND; -use windows::Win32::UI::Shell::{ - Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD, - NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS, -}; -use windows::Win32::UI::WindowsAndMessaging::DestroyIcon; - -pub mod badge; -pub mod callback; - -pub use crate::usage::ProviderId as IconKind; - -/// Menu-command ID for the "Show widget" toggle that appears in the -/// right-click menu and is also fired by left-clicking a tray icon. -pub const IDM_TOGGLE_WIDGET: u16 = 50; - -/// Notification message routed back to the owner HWND when the user -/// interacts with a tray icon (left/right click). -pub const WM_APP_TRAY: u32 = 0x8003; - -#[derive(Clone, Debug)] -pub struct TrayIcon { - pub kind: IconKind, - pub percent: Option, - pub tooltip: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrayAction { - None, - ToggleWidget, - ShowContextMenu, -} - -fn registered() -> &'static Mutex> { - static R: OnceLock>> = OnceLock::new(); - R.get_or_init(|| Mutex::new(HashSet::new())) -} - -/// Reconcile registered icons with the desired list. -pub fn sync(owner: HWND, desired: &[TrayIcon]) { - let mut current = registered().lock().expect("tray registry mutex poisoned"); - let target: HashSet = desired.iter().map(|i| i.kind).collect(); - - let to_remove: Vec = current.difference(&target).copied().collect(); - for kind in to_remove { - unsafe { - let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind)); - } - current.remove(&kind); - } - - for icon in desired { - let hicon = badge::render_hicon(icon.kind, icon.percent); - let mut data = build_data(owner, icon.kind); - data.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; - data.hIcon = hicon; - write_utf16(&mut data.szTip, &icon.tooltip); - let msg = if current.contains(&icon.kind) { - NIM_MODIFY - } else { - NIM_ADD - }; - unsafe { - if Shell_NotifyIconW(msg, &data).as_bool() && msg == NIM_ADD { - current.insert(icon.kind); - } - let _ = DestroyIcon(hicon); - } - } -} - -/// Show a yellow-warning balloon on an already-registered icon. -pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) { - notify_inner(owner, kind, title, body, NIIF_WARNING); -} - -/// Show a blue-info balloon on an already-registered icon. -pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) { - notify_inner(owner, kind, title, body, NIIF_INFO); -} - -fn notify_inner( - owner: HWND, - kind: IconKind, - title: &str, - body: &str, - flags: NOTIFY_ICON_INFOTIP_FLAGS, -) { - let mut data = build_data(owner, kind); - data.uFlags = NIF_INFO; - write_utf16(&mut data.szInfoTitle, title); - write_utf16(&mut data.szInfo, body); - data.dwInfoFlags = flags; - unsafe { - let _ = Shell_NotifyIconW(NIM_MODIFY, &data); - } -} - -/// Tear down every registered icon. Call from app shutdown if you want to. -#[allow(dead_code)] -pub fn remove_all(owner: HWND) { - let mut current = registered().lock().expect("tray registry mutex poisoned"); - for kind in current.drain().collect::>() { - unsafe { - let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind)); - } - } -} - -fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW { - NOTIFYICONDATAW { - cbSize: std::mem::size_of::() as u32, - hWnd: owner, - uID: icon_id(kind), - uCallbackMessage: WM_APP_TRAY, - ..Default::default() - } -} - -fn icon_id(kind: IconKind) -> u32 { - kind.metadata().tray_icon_id -} - -fn write_utf16(dst: &mut [u16], src: &str) { - let units: Vec = src.encode_utf16().collect(); - let n = units.len().min(dst.len().saturating_sub(1)); - dst[..n].copy_from_slice(&units[..n]); - if n < dst.len() { - dst[n] = 0; - } -}