diff --git a/src/core/color.rs b/src/core/color.rs new file mode 100644 index 0000000..11cfb93 --- /dev/null +++ b/src/core/color.rs @@ -0,0 +1,113 @@ +//! Plain RGB colour helpers shared by the bubble, the panel and the tray badge. +//! +//! The webview takes colours as `#RRGGBB` strings, so [`Rgb::to_hex`] is the +//! boundary conversion. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + /// Parse `#RRGGBB` or `RRGGBB`. Panics on malformed input — meant for + /// hardcoded design-system literals at compile-known call sites. + pub fn from_hex(hex: &str) -> Self { + Self::parse_hex(hex).unwrap_or_else(|| panic!("invalid hex color literal: {hex:?}")) + } + + /// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input. + pub fn parse_hex(hex: &str) -> Option { + let s = hex.trim_start_matches('#'); + if s.len() != 6 || !s.is_ascii() { + return None; + } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some(Self { r, g, b }) + } + + /// Format as an uppercase `#RRGGBB` CSS colour. + pub fn to_hex(self) -> String { + format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b) + } + + /// Linear interpolation between two colours. `t` is clamped to `[0, 1]`. + pub fn lerp(self, other: Rgb, t: f64) -> Rgb { + let t = t.clamp(0.0, 1.0); + let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8; + Rgb { + r: mix(self.r, other.r), + g: mix(self.g, other.g), + b: mix(self.b, other.b), + } + } +} + +/// Triangle wave in [0, 1] with a period of 24 ticks: 0 at phase 0, 0.5 at +/// phase 6 and 18, and 1 at phase 12. The bubble UI mirrors this exactly. +pub 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]. +pub fn brighten(c: Rgb, t: f64) -> Rgb { + // Map t to a smaller brightness delta — the pulse should be a subtle nudge. + let t = t.clamp(0.0, 1.0) * 0.30; + Rgb::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, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_round_trips_and_rejects_malformed_input() { + let c = Rgb::parse_hex("#d97757").expect("valid hex"); + assert_eq!(c, Rgb::new(0xD9, 0x77, 0x57)); + assert_eq!(c.to_hex(), "#D97757"); + assert_eq!(Rgb::parse_hex("1A1A1A"), Some(Rgb::new(0x1A, 0x1A, 0x1A))); + assert_eq!(Rgb::parse_hex("#12345"), None); + assert_eq!(Rgb::parse_hex("#GG0000"), None); + assert_eq!(Rgb::parse_hex("#ééé"), None); + } + + #[test] + fn pulse_triangle_peaks_mid_period() { + assert_eq!(pulse_triangle(0), 0.0); + assert_eq!(pulse_triangle(6), 0.5); + assert_eq!(pulse_triangle(12), 1.0); + assert_eq!(pulse_triangle(18), 0.5); + assert_eq!(pulse_triangle(24), 0.0); + } + + #[test] + fn brighten_caps_at_thirty_percent_toward_white() { + let c = Rgb::new(0xA0, 0x18, 0x18); + assert_eq!(brighten(c, 0.0), c); + // 160 + 95 * 0.3 = 188.5 -> 189; 24 + 231 * 0.3 = 93.3 -> 93 + assert_eq!(brighten(c, 1.0), Rgb::new(189, 93, 93)); + assert_eq!(brighten(c, 5.0), brighten(c, 1.0)); + } + + #[test] + fn lerp_clamps_t() { + let a = Rgb::new(0, 0, 0); + let b = Rgb::new(255, 255, 255); + assert_eq!(a.lerp(b, -1.0), a); + assert_eq!(a.lerp(b, 2.0), b); + assert_eq!(a.lerp(b, 0.5), Rgb::new(128, 128, 128)); + } +} diff --git a/src/core/geom.rs b/src/core/geom.rs new file mode 100644 index 0000000..a1cc0d8 --- /dev/null +++ b/src/core/geom.rs @@ -0,0 +1,776 @@ +//! Window geometry in physical pixels: bubble sizing, default placement, +//! snapping, clamping, panel placement and fullscreen checks. +//! +//! Everything here is pure. The window layer collects monitors, work areas, +//! the taskbar and peer bubbles, and passes them in as plain [`Rect`]s. + +use crate::usage::ProviderId; + +/// Screen rectangle with exclusive right/bottom edges, like Win32 `RECT`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Rect { + pub l: i32, + pub t: i32, + pub r: i32, + pub b: i32, +} + +impl Rect { + pub const fn new(l: i32, t: i32, r: i32, b: i32) -> Self { + Self { l, t, r, b } + } + + /// Rect from a top-left corner and a size. + pub const fn from_xywh(x: i32, y: i32, w: i32, h: i32) -> Self { + Self { + l: x, + t: y, + r: x + w, + b: y + h, + } + } + + pub const fn w(&self) -> i32 { + self.r - self.l + } + + pub const fn h(&self) -> i32 { + self.b - self.t + } + + pub const fn is_empty(&self) -> bool { + self.r <= self.l || self.b <= self.t + } + + /// True when the two rects share a non-empty area. + pub const fn intersects(&self, other: &Rect) -> bool { + self.l < other.r && other.l < self.r && self.t < other.b && other.t < self.b + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Point { + pub x: i32, + pub y: i32, +} + +impl Point { + pub const fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +/// The DPI the OS reports for 100% scaling. +pub const BASE_DPI: u32 = 96; + +/// Scale a logical (96-DPI) pixel measurement to the given DPI. +pub fn scale(logical_px: i32, dpi: u32) -> i32 { + ((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32 +} + +// ---------- Bubble size ---------- + +// 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 = 200; +pub const RESIZE_STEP_LOGICAL: i32 = 20; + +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; + +/// Raw (unscaled) gap used by the default position and the Codex stagger. +const STAGGER_GAP: i32 = 24; + +/// Side of the square probed when validating a saved position. 140 px +/// matches `MIN_BUBBLE_SIZE`: a saved top-left a few px past the work-area +/// edge still passes the validator, but a position fully on a disconnected +/// monitor fails. +const POSITION_PROBE_PX: i32 = 140; + +/// Gap between a bubble and its details panel, in physical px. +const PANEL_GAP_PX: i32 = 8; + +const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2; + +/// (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. +pub 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 fn bubble_height_logical(width_logical: i32) -> i32 { + let (num, den) = aspect_at_width(width_logical); + ((width_logical * den) / num).max(20) +} + +/// Physical `(width, height)` of a bubble at `size_logical` and `dpi`. +pub fn bubble_size_px(size_logical: i32, dpi: u32) -> (i32, i32) { + ( + scale(size_logical, dpi), + scale(bubble_height_logical(size_logical), dpi), + ) +} + +pub fn corner_radius_px(height_px: i32) -> i32 { + height_px / 2 +} + +/// Whether the local point `(x, y)` lies inside a `w`×`h` rounded rect with +/// corner radius `r`. +pub fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool { + if x < 0 || y < 0 || x >= w || y >= h { + return false; + } + // The straight horizontal and vertical strips are always inside; only the + // four corner squares need the circular falloff check. + let in_x_strip = x >= r && x < w - r; + let in_y_strip = y >= r && y < h - r; + if in_x_strip || in_y_strip { + return true; + } + let cx = if x < r { r } else { w - 1 - r }; + let cy = if y < r { r } else { h - 1 - r }; + let dx = x - cx; + let dy = y - cy; + dx * dx + dy * dy <= r * r +} + +// ---------- Placement ---------- + +/// Bottom-right of the primary work area, with a 24-pixel gap from the +/// edges. The Codex bubble is staggered above the Claude one so both fit +/// when enabled. +pub fn default_position( + work_area: Rect, + width_px: i32, + height_px: i32, + model: ProviderId, +) -> Point { + let stagger = match model { + ProviderId::Claude => 0, + ProviderId::ChatGpt => height_px + STAGGER_GAP, + ProviderId::OpenCodeGo => 2 * (height_px + STAGGER_GAP), + }; + let x = work_area.r - width_px - STAGGER_GAP; + let y = work_area.b - height_px - STAGGER_GAP - stagger; + Point::new(x, y) +} + +/// Keep `rect` inside `work_area` and return the clamped top-left. +/// +/// When both bubbles get clamped to the same bottom-right corner (e.g. +/// saved positions were on a disconnected monitor and the validator missed +/// them), the Codex bubble keeps the stagger `default_position` uses so they +/// don't visually stack. +pub fn clamp_into_work_area(rect: Rect, work_area: Rect, model: ProviderId) -> Point { + let (w, h) = (rect.w(), rect.h()); + let nx = rect + .l + .clamp(work_area.l, (work_area.r - w).max(work_area.l)); + let mut ny = rect + .t + .clamp(work_area.t, (work_area.b - h).max(work_area.t)); + if model == ProviderId::ChatGpt && nx == work_area.r - w && ny == work_area.b - h { + ny = (ny - h - STAGGER_GAP).max(work_area.t); + } + Point::new(nx, ny) +} + +/// Monitor edge the taskbar is docked to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + Left, + Top, + Right, + Bottom, +} + +/// Everything the drag-end snap needs, in physical px. +#[derive(Clone, Debug)] +pub struct SnapInput { + /// The dragged bubble's current outer rect. + pub rect: Rect, + /// Work area of the monitor nearest the bubble. + pub work_area: Rect, + /// DPI of the bubble's monitor. + pub dpi: u32, + /// Docked taskbar edge and rect, when the platform reports one. + pub taskbar: Option<(Edge, Rect)>, + /// Outer rects of the other bubbles, in `ProviderId::LIVE_USAGE` order. + pub peers: Vec, +} + +/// Snap a dropped bubble: corner first; otherwise edge, taskbar and peer +/// alignment; then always clamp into the work area. Returns the new +/// top-left, which equals `input.rect`'s when nothing moves. +pub fn snap(input: &SnapInput) -> Point { + let dpi = input.dpi; + let edge_zone = scale(SNAP_ZONE_LOGICAL, dpi); + let corner_zone = scale(CORNER_SNAP_ZONE_LOGICAL, dpi); + let corner_inset = scale(CORNER_INSET_LOGICAL, dpi); + let taskbar_gap = scale(TASKBAR_GAP_LOGICAL, dpi); + let peer_tolerance = scale(PEER_ALIGN_TOLERANCE_LOGICAL, dpi); + + let wa = &input.work_area; + let w = input.rect.w(); + let h = input.rect.h(); + let mut nx = input.rect.l; + let mut ny = input.rect.t; + + // 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 — also handles taskbar adjacency when the taskbar + // steals from the work area on the same edge. + snap_to_work_area_edges(&mut nx, &mut ny, wa, w, h, edge_zone); + if let Some((edge, tb)) = &input.taskbar { + let fit = Fit { + wa, + w, + h, + zone: edge_zone, + }; + snap_alongside_taskbar(&mut nx, &mut ny, *edge, tb, &fit, taskbar_gap); + } + + // 3. Peer vertical alignment — when another bubble is within ±8 px + // on Y, snap the dragged bubble to share its baseline. + align_with_peer(&input.peers, &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.l, (wa.r - w).max(wa.l)); + ny = ny.clamp(wa.t, (wa.b - h).max(wa.t)); + Point::new(nx, ny) +} + +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.l).abs() + (*ny - wa.t).abs(); + let tr = (wa.r - (*nx + w)).abs() + (*ny - wa.t).abs(); + let bl = (*nx - wa.l).abs() + (wa.b - (*ny + h)).abs(); + let br = (wa.r - (*nx + w)).abs() + (wa.b - (*ny + h)).abs(); + let min = tl.min(tr).min(bl).min(br); + if min > zone * 2 { + return false; + } + if min == tl { + *nx = wa.l + inset; + *ny = wa.t + inset; + } else if min == tr { + *nx = wa.r - inset - w; + *ny = wa.t + inset; + } else if min == bl { + *nx = wa.l + inset; + *ny = wa.b - inset - h; + } else { + *nx = wa.r - inset - w; + *ny = wa.b - 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.l).abs() < zone { + *nx = wa.l; + } else if (wa.r - (*nx + w)).abs() < zone { + *nx = wa.r - w; + } + if (*ny - wa.t).abs() < zone { + *ny = wa.t; + } else if (wa.b - (*ny + h)).abs() < zone { + *ny = wa.b - h; + } +} + +/// Work area, bubble size and snap zone shared by the taskbar snap. +struct Fit<'a> { + wa: &'a Rect, + w: i32, + h: i32, + zone: i32, +} + +fn snap_alongside_taskbar( + nx: &mut i32, + ny: &mut i32, + edge: Edge, + tb: &Rect, + fit: &Fit<'_>, + gap: i32, +) { + let Fit { wa, w, h, zone } = *fit; + // 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 edge { + Edge::Bottom => { + let target = tb.t - gap - h; + if (*ny - target).abs() < zone { + *ny = target.max(wa.t); + } + } + Edge::Top => { + let target = tb.b + gap; + if (*ny - target).abs() < zone { + *ny = target.min(wa.b - h); + } + } + Edge::Left => { + let target = tb.r + gap; + if (*nx - target).abs() < zone { + *nx = target.min(wa.r - w); + } + } + Edge::Right => { + let target = tb.l - gap - w; + if (*nx - target).abs() < zone { + *nx = target.max(wa.l); + } + } + } +} + +fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) { + if let Some(peer) = peers.iter().find(|p| (*ny - p.t).abs() <= tolerance) { + *ny = peer.t; + } +} + +/// Place a `panel_w`×`panel_h` panel below `anchor` (the bubble's outer +/// rect), flipping above when it would clip, and keep it inside `bounds` +/// (the virtual-screen union of all monitors, whose origin can be negative). +pub fn place_near(anchor: Rect, panel_size: (i32, i32), bounds: Rect) -> Point { + let (panel_w, panel_h) = panel_size; + let mut x = anchor.l; + let mut y = anchor.b + PANEL_GAP_PX; + if y + panel_h > bounds.b { + y = anchor.t - panel_h - PANEL_GAP_PX; + } + if y < bounds.t { + y = anchor.t.max(bounds.t); + } + if x + panel_w > bounds.r { + x = bounds.r - panel_w - PANEL_GAP_PX; + } + if x < bounds.l { + x = bounds.l + PANEL_GAP_PX; + } + Point::new(x, y) +} + +/// Whether a saved top-left `(x, y)` still lands on a connected monitor: +/// a `140×140` probe there must overlap at least one of `monitors`. +pub fn position_on_any_monitor(x: i32, y: i32, monitors: &[Rect]) -> bool { + let probe = Rect::from_xywh(x, y, POSITION_PROBE_PX, POSITION_PROBE_PX); + monitors.iter().any(|m| probe.intersects(m)) +} + +// ---------- Fullscreen ---------- + +/// Whether `rect` covers the monitor `bounds`, allowing 2 px of slack per edge. +pub fn rect_covers_monitor(rect: &Rect, bounds: &Rect) -> bool { + rect.l <= bounds.l + FULLSCREEN_EDGE_TOLERANCE_PX + && rect.t <= bounds.t + FULLSCREEN_EDGE_TOLERANCE_PX + && rect.r >= bounds.r - FULLSCREEN_EDGE_TOLERANCE_PX + && rect.b >= bounds.b - FULLSCREEN_EDGE_TOLERANCE_PX +} + +// Win32 window style bits (`WS_*`), as the plain values `GWL_STYLE` returns. +pub const WS_CHILD: u32 = 0x4000_0000; +pub const WS_POPUP: u32 = 0x8000_0000; +pub const WS_CAPTION: u32 = 0x00C0_0000; +pub const WS_THICKFRAME: u32 = 0x0004_0000; + +/// A fullscreen candidate is a top-level popup, or a top-level window with +/// neither a caption nor a resize frame. +pub fn window_style_bits_allow_fullscreen(style: u32) -> bool { + let has_child = style & WS_CHILD != 0; + let has_popup = style & WS_POPUP != 0; + let has_caption = style & WS_CAPTION != 0; + let has_resize_frame = style & WS_THICKFRAME != 0; + + !has_child && (has_popup || (!has_caption && !has_resize_frame)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const WA: Rect = Rect::new(0, 0, 1920, 1040); + + fn input(rect: Rect) -> SnapInput { + SnapInput { + rect, + work_area: WA, + dpi: 96, + taskbar: None, + peers: Vec::new(), + } + } + + #[test] + fn scale_follows_dpi() { + assert_eq!(scale(200, 96), 200); + assert_eq!(scale(200, 144), 300); + assert_eq!(scale(12, 120), 15); + assert_eq!(scale(66, 168), 115); + } + + #[test] + fn aspect_and_height_per_width() { + for (w, aspect, h) in [ + (140, (3, 1), 46), + (200, (3, 1), 66), + (220, (14, 5), 78), + (280, (14, 5), 100), + (300, (13, 5), 115), + (360, (13, 5), 138), + ] { + assert_eq!(aspect_at_width(w), aspect, "aspect at {w}"); + assert_eq!(bubble_height_logical(w), h, "height at {w}"); + } + assert_eq!(bubble_size_px(200, 144), (300, 99)); + } + + #[test] + fn rounded_rect_excludes_corners_only() { + let (w, h) = (200, 66); + let r = corner_radius_px(h); + assert!(point_in_rounded_rect(100, 0, w, h, r)); + assert!(point_in_rounded_rect(33, 33, w, h, r)); + assert!(!point_in_rounded_rect(0, 0, w, h, r)); + assert!(!point_in_rounded_rect(w - 1, h - 1, w, h, r)); + assert!(point_in_rounded_rect(1, 33, w, h, r)); + assert!(!point_in_rounded_rect(0, 33, w, h, r)); + assert!(!point_in_rounded_rect(-1, 33, w, h, r)); + assert!(!point_in_rounded_rect(w, 33, w, h, r)); + } + + #[test] + fn default_position_staggers_by_provider() { + let (w, h) = (200, 66); + assert_eq!( + default_position(WA, w, h, ProviderId::Claude), + Point::new(1920 - 200 - 24, 1040 - 66 - 24) + ); + assert_eq!( + default_position(WA, w, h, ProviderId::ChatGpt), + Point::new(1696, 1040 - 66 - 24 - 90) + ); + assert_eq!( + default_position(WA, w, h, ProviderId::OpenCodeGo), + Point::new(1696, 1040 - 66 - 24 - 180) + ); + } + + #[test] + fn clamp_pulls_offscreen_rect_into_work_area() { + let r = Rect::from_xywh(-500, -30, 200, 66); + assert_eq!( + clamp_into_work_area(r, WA, ProviderId::Claude), + Point::new(0, 0) + ); + let inside = Rect::from_xywh(100, 100, 200, 66); + assert_eq!( + clamp_into_work_area(inside, WA, ProviderId::ChatGpt), + Point::new(100, 100) + ); + } + + #[test] + fn clamp_lifts_codex_out_of_bottom_right_corner() { + let r = Rect::from_xywh(5000, 5000, 200, 66); + assert_eq!( + clamp_into_work_area(r, WA, ProviderId::Claude), + Point::new(1720, 974) + ); + assert_eq!( + clamp_into_work_area(r, WA, ProviderId::ChatGpt), + Point::new(1720, 974 - 66 - 24) + ); + // The lift never goes above the work area. + let tiny = Rect::new(0, 0, 400, 100); + assert_eq!( + clamp_into_work_area( + Rect::from_xywh(900, 900, 200, 66), + tiny, + ProviderId::ChatGpt + ), + Point::new(200, 0) + ); + } + + #[test] + fn corner_snap_insets_into_each_corner() { + // Within 64 px (2 x 32) Manhattan distance of a corner. + let tl = snap(&input(Rect::from_xywh(20, 30, 200, 66))); + assert_eq!(tl, Point::new(12, 12)); + let br = snap(&input(Rect::from_xywh(1690, 940, 200, 66))); + assert_eq!(br, Point::new(1920 - 12 - 200, 1040 - 12 - 66)); + let tr = snap(&input(Rect::from_xywh(1700, 10, 200, 66))); + assert_eq!(tr, Point::new(1708, 12)); + let bl = snap(&input(Rect::from_xywh(10, 950, 200, 66))); + assert_eq!(bl, Point::new(12, 962)); + } + + #[test] + fn corner_snap_tie_order_is_tl_tr_bl_br() { + // The work area leaves 40 px of slack on both axes, so the four + // corner distances can be made to tie. + let wa = Rect::new(0, 0, 240, 106); + let at = |x, y| SnapInput { + work_area: wa, + ..input(Rect::from_xywh(x, y, 200, 66)) + }; + // tl = tr = 20 (bl = br = 60): top-left wins. + assert_eq!(snap(&at(20, 0)), Point::new(12, 12)); + // tl = 50, tr = br = 30: top-right wins over bottom-right. + assert_eq!(snap(&at(30, 20)), Point::new(28, 12)); + // tl = tr = 50, bl = br = 30: bottom-left wins over bottom-right. + assert_eq!(snap(&at(20, 30)), Point::new(12, 28)); + // Only bottom-right is nearest. + assert_eq!(snap(&at(30, 30)), Point::new(28, 28)); + } + + #[test] + fn corner_zone_boundary_is_inclusive_at_twice_the_zone() { + // tl distance exactly 64 snaps; 65 does not. + assert_eq!( + snap(&input(Rect::from_xywh(32, 32, 200, 66))), + Point::new(12, 12) + ); + let out = snap(&input(Rect::from_xywh(33, 32, 200, 66))); + assert_ne!(out, Point::new(12, 12)); + // Not a corner: falls through to edge snap on the top edge (32 >= 12 + // so x stays; y=32 is outside the 12 px edge zone too). + assert_eq!(out, Point::new(33, 32)); + } + + #[test] + fn edge_zone_is_exclusive_at_twelve_px() { + // 11 px from the left edge snaps flush; 12 px does not. + assert_eq!( + snap(&input(Rect::from_xywh(11, 500, 200, 66))), + Point::new(0, 500) + ); + assert_eq!( + snap(&input(Rect::from_xywh(12, 500, 200, 66))), + Point::new(12, 500) + ); + // Right and bottom edges. + assert_eq!( + snap(&input(Rect::from_xywh(1709, 500, 200, 66))), + Point::new(1720, 500) + ); + assert_eq!( + snap(&input(Rect::from_xywh(800, 965, 200, 66))), + Point::new(800, 974) + ); + assert_eq!( + snap(&input(Rect::from_xywh(800, 5, 200, 66))), + Point::new(800, 0) + ); + } + + #[test] + fn edge_zone_scales_with_dpi() { + let mut i = input(Rect::from_xywh(17, 500, 300, 99)); + i.dpi = 144; // zone 18 px + assert_eq!(snap(&i), Point::new(0, 500)); + i.rect = Rect::from_xywh(18, 500, 300, 99); + assert_eq!(snap(&i), Point::new(18, 500)); + } + + #[test] + fn taskbar_snap_on_each_edge() { + // Monitor 1920x1080; the work area excludes a 40 px taskbar. + let cases = [ + ( + Edge::Bottom, + Rect::new(0, 1040, 1920, 1080), + Rect::new(0, 0, 1920, 1040), + Rect::from_xywh(800, 960, 200, 66), + Point::new(800, 1040 - 4 - 66), + ), + ( + Edge::Top, + Rect::new(0, 0, 1920, 40), + Rect::new(0, 40, 1920, 1080), + Rect::from_xywh(800, 52, 200, 66), + Point::new(800, 44), + ), + ( + Edge::Left, + Rect::new(0, 0, 40, 1080), + Rect::new(40, 0, 1920, 1080), + Rect::from_xywh(52, 500, 200, 66), + Point::new(44, 500), + ), + ( + Edge::Right, + Rect::new(1880, 0, 1920, 1080), + Rect::new(0, 0, 1880, 1080), + Rect::from_xywh(1666, 500, 200, 66), + Point::new(1880 - 4 - 200, 500), + ), + ]; + for (edge, tb, wa, rect, expected) in cases { + let i = SnapInput { + rect, + work_area: wa, + dpi: 96, + taskbar: Some((edge, tb)), + peers: Vec::new(), + }; + assert_eq!(snap(&i), expected, "taskbar {edge:?}"); + } + } + + #[test] + fn taskbar_snap_ignores_far_positions() { + let i = SnapInput { + rect: Rect::from_xywh(800, 900, 200, 66), + work_area: WA, + dpi: 96, + taskbar: Some((Edge::Bottom, Rect::new(0, 1040, 1920, 1080))), + peers: Vec::new(), + }; + assert_eq!(snap(&i), Point::new(800, 900)); + } + + #[test] + fn peer_alignment_takes_first_peer_within_tolerance() { + let mut i = input(Rect::from_xywh(800, 500, 200, 66)); + i.peers = vec![ + Rect::from_xywh(100, 509, 200, 66), + Rect::from_xywh(400, 506, 200, 66), + ]; + // 9 px is outside the 8 px tolerance; 6 px is inside. + assert_eq!(snap(&i), Point::new(800, 506)); + i.peers = vec![ + Rect::from_xywh(100, 508, 200, 66), + Rect::from_xywh(400, 506, 200, 66), + ]; + assert_eq!(snap(&i), Point::new(800, 508)); + i.peers.clear(); + assert_eq!(snap(&i), Point::new(800, 500)); + } + + #[test] + fn peer_alignment_is_skipped_after_a_corner_snap() { + let mut i = input(Rect::from_xywh(20, 20, 200, 66)); + i.peers = vec![Rect::from_xywh(900, 18, 200, 66)]; + assert_eq!(snap(&i), Point::new(12, 12)); + } + + #[test] + fn final_clamp_keeps_peer_aligned_bubble_on_screen() { + // The bottom edge snap puts y at 974; aligning to the peer at 980 + // would overflow the 1040 bottom, so the clamp wins. + let mut i = input(Rect::from_xywh(800, 970, 200, 66)); + i.peers = vec![Rect::from_xywh(100, 980, 200, 30)]; + assert_eq!(snap(&i), Point::new(800, 974)); + // A rect larger than the work area pins to its top-left. + let mut big = input(Rect::from_xywh(500, 500, 3000, 2000)); + big.work_area = Rect::new(100, 50, 1920, 1040); + assert_eq!(snap(&big), Point::new(100, 50)); + } + + #[test] + fn place_near_below_then_above() { + let bounds = Rect::new(0, 0, 1920, 1080); + let bubble = Rect::from_xywh(100, 100, 200, 66); + assert_eq!(place_near(bubble, (280, 120), bounds), Point::new(100, 174)); + let low = Rect::from_xywh(100, 950, 200, 66); + assert_eq!(place_near(low, (280, 120), bounds), Point::new(100, 822)); + } + + #[test] + fn place_near_clamps_left_and_right() { + let bounds = Rect::new(0, 0, 1920, 1080); + let right = Rect::from_xywh(1800, 100, 200, 66); + assert_eq!( + place_near(right, (280, 120), bounds), + Point::new(1920 - 280 - 8, 174) + ); + let left = Rect::from_xywh(-50, 100, 200, 66); + assert_eq!(place_near(left, (280, 120), bounds), Point::new(8, 174)); + } + + #[test] + fn place_near_handles_negative_virtual_origin() { + // A secondary monitor left of and above the primary. + let bounds = Rect::new(-1920, -200, 1920, 1080); + let bubble = Rect::from_xywh(-1900, -190, 200, 66); + assert_eq!( + place_near(bubble, (280, 120), bounds), + Point::new(-1900, -116) + ); + let edge = Rect::from_xywh(-2000, 1000, 200, 66); + assert_eq!( + place_near(edge, (280, 120), bounds), + Point::new(-1912, 1000 - 120 - 8) + ); + // Flipping above would leave the top too; it pins to the anchor top. + let tall_bounds = Rect::new(-1920, -200, 1920, 100); + let b = Rect::from_xywh(0, -190, 200, 66); + assert_eq!(place_near(b, (280, 400), tall_bounds), Point::new(0, -190)); + } + + #[test] + fn position_probe_against_monitors() { + let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)]; + assert!(position_on_any_monitor(100, 100, &monitors)); + assert!(position_on_any_monitor(2000, 500, &monitors)); + // Partially off the left edge still overlaps. + assert!(position_on_any_monitor(-100, 100, &monitors)); + // Fully past the left edge does not. + assert!(!position_on_any_monitor(-140, 100, &monitors)); + assert!(!position_on_any_monitor(4000, 100, &monitors)); + assert!(!position_on_any_monitor(100, 100, &[])); + } + + #[test] + fn style_bits_allow_popup_or_borderless_windows_only() { + assert!(window_style_bits_allow_fullscreen(WS_POPUP)); + assert!(window_style_bits_allow_fullscreen(0)); + assert!(!window_style_bits_allow_fullscreen( + WS_CAPTION | WS_THICKFRAME + )); + assert!(!window_style_bits_allow_fullscreen(WS_CHILD | WS_POPUP)); + } + + #[test] + fn rect_cover_check_allows_small_edge_differences() { + let monitor = Rect::new(0, 0, 1920, 1080); + let almost_exact = Rect::new(1, 2, 1919, 1078); + let inset = Rect::new(8, 0, 1920, 1080); + + assert!(rect_covers_monitor(&almost_exact, &monitor)); + assert!(!rect_covers_monitor(&inset, &monitor)); + } +} diff --git a/src/i18n/mod.rs b/src/core/i18n.rs similarity index 63% rename from src/i18n/mod.rs rename to src/core/i18n.rs index 3e725df..aeca9fd 100644 --- a/src/i18n/mod.rs +++ b/src/core/i18n.rs @@ -1,7 +1,12 @@ // English UI strings and formatting helpers. +// +// Every string the UI displays is formatted here; the webview never formats +// a number for display. use std::time::{Duration, SystemTime}; +use crate::usage::{ProviderId, Window}; + /// The strings every UI module needs. #[derive(Clone, Debug)] pub struct LocaleStrings { @@ -18,6 +23,7 @@ pub struct LocaleStrings { pub opencode_go_label: String, pub settings: String, pub start_with_windows: String, + pub start_at_login: String, pub reset_position: String, pub size_smaller: String, pub size_larger: String, @@ -85,6 +91,26 @@ impl I18n { } } +impl LocaleStrings { + /// Label of the autostart menu item for the platform this build targets. + pub fn autostart_label(&self) -> &str { + if cfg!(windows) { + &self.start_with_windows + } else { + &self.start_at_login + } + } + + /// Display name of a provider. + pub fn provider_label(&self, provider: ProviderId) -> &str { + match provider { + ProviderId::Claude => &self.claude_label, + ProviderId::ChatGpt => &self.chatgpt_label, + ProviderId::OpenCodeGo => &self.opencode_go_label, + } + } +} + fn english_strings() -> LocaleStrings { LocaleStrings { window_title: "Claude Code Usage Bubble".into(), @@ -100,6 +126,7 @@ fn english_strings() -> LocaleStrings { opencode_go_label: "OpenCode Go".into(), settings: "Settings".into(), start_with_windows: "Start with Windows".into(), + start_at_login: "Start at login".into(), reset_position: "Reset position".into(), size_smaller: "Make smaller".into(), size_larger: "Make larger".into(), @@ -147,11 +174,24 @@ fn english_strings() -> LocaleStrings { // ---------- Free-function helpers ---------- +/// A percentage rounded half-to-even (`"72%"`), or `"—"` when there is none. +pub fn format_pct(pct: Option) -> String { + match pct { + Some(p) => format!("{p:.0}%"), + None => String::from("\u{2014}"), + } +} + /// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text. /// Returns just the percentage when no reset time is available. -pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> String { +pub fn format_window(window: &Window, strings: &LocaleStrings) -> String { + format_window_at(window, strings, SystemTime::now()) +} + +/// [`format_window`] evaluated at `now`. +pub fn format_window_at(window: &Window, strings: &LocaleStrings, now: SystemTime) -> String { let pct = format!("{:.0}%", window.utilization); - let cd = format_countdown(window.resets_at, strings); + let cd = format_countdown_at(window.resets_at, strings, now); if cd.is_empty() { pct } else { @@ -162,10 +202,19 @@ pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> /// 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 { + format_countdown_at(resets_at, strings, SystemTime::now()) +} + +/// [`format_countdown`] evaluated at `now`. +pub fn format_countdown_at( + resets_at: Option, + strings: &LocaleStrings, + now: SystemTime, +) -> String { let Some(reset) = resets_at else { return String::new(); }; - let remaining = match reset.duration_since(SystemTime::now()) { + let remaining = match reset.duration_since(now) { Ok(d) => d, Err(_) => return strings.now.clone(), }; @@ -190,8 +239,16 @@ fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String { /// How long before `format_window`'s string would change. /// Used by the countdown timer to refresh exactly when needed. pub fn time_until_display_change(resets_at: Option) -> Option { + time_until_display_change_at(resets_at, SystemTime::now()) +} + +/// [`time_until_display_change`] evaluated at `now`. +pub fn time_until_display_change_at( + resets_at: Option, + now: SystemTime, +) -> Option { let reset = resets_at?; - let remaining = reset.duration_since(SystemTime::now()).ok()?; + let remaining = reset.duration_since(now).ok()?; let secs = remaining.as_secs(); let bucket_start = if secs / 86_400 >= 1 { (secs / 86_400) * 86_400 @@ -230,4 +287,85 @@ mod tests { assert!(!value.trim().is_empty(), "empty string: {name}"); } } + + fn at(now: SystemTime, secs: u64) -> Option { + Some(now + Duration::from_secs(secs)) + } + + #[test] + fn countdown_buckets() { + let s = english_strings(); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let cd = |secs| format_countdown_at(at(now, secs), &s, now); + assert_eq!(format_countdown_at(None, &s, now), ""); + assert_eq!( + format_countdown_at(Some(now - Duration::from_secs(1)), &s, now), + "now" + ); + assert_eq!(cd(0), "0s"); + assert_eq!(cd(59), "59s"); + assert_eq!(cd(60), "1m"); + assert_eq!(cd(3_599), "59m"); + assert_eq!(cd(3_600), "1h"); + assert_eq!(cd(86_399), "23h"); + assert_eq!(cd(86_400), "1d"); + assert_eq!(cd(86_400 + 23 * 3_600), "1d"); + assert_eq!(cd(7 * 86_400), "7d"); + } + + #[test] + fn window_text_joins_percent_and_countdown() { + let s = english_strings(); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let w = Window { + utilization: 73.2, + resets_at: at(now, 2 * 3_600 + 5), + }; + assert_eq!(format_window_at(&w, &s, now), "73% \u{00b7} 2h"); + let bare = Window { + utilization: 73.2, + resets_at: None, + }; + assert_eq!(format_window_at(&bare, &s, now), "73%"); + } + + #[test] + fn display_change_fires_just_past_the_bucket_edge() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let ttl = |secs| time_until_display_change_at(at(now, secs), now); + assert_eq!(time_until_display_change_at(None, now), None); + assert_eq!( + time_until_display_change_at(Some(now - Duration::from_secs(5)), now), + None + ); + // 1d 23h: "1d" stays until 23h + 1s from now. + assert_eq!( + ttl(86_400 + 23 * 3_600), + Some(Duration::from_secs(23 * 3_600 + 1)) + ); + assert_eq!(ttl(2 * 3_600 + 30), Some(Duration::from_secs(31))); + assert_eq!(ttl(5 * 60 + 7), Some(Duration::from_secs(8))); + // Under a minute the text changes every second. + assert_eq!(ttl(42), Some(Duration::from_secs(1))); + } + + #[test] + fn percent_rounds_half_to_even() { + assert_eq!(format_pct(Some(72.5)), "72%"); + assert_eq!(format_pct(Some(73.5)), "74%"); + assert_eq!(format_pct(Some(0.0)), "0%"); + assert_eq!(format_pct(None), "\u{2014}"); + } + + #[test] + fn autostart_label_matches_the_platform() { + let s = english_strings(); + let expected = if cfg!(windows) { + "Start with Windows" + } else { + "Start at login" + }; + assert_eq!(s.autostart_label(), expected); + assert_eq!(s.provider_label(ProviderId::ChatGpt), "Codex"); + } } diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..9872937 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,7 @@ +//! Portable logic with no OS or Tauri dependency, unit-tested on every host. + +pub mod color; +pub mod geom; +pub mod i18n; +pub mod settings; +pub mod usage_color; diff --git a/src/core/settings.rs b/src/core/settings.rs new file mode 100644 index 0000000..28c1e48 --- /dev/null +++ b/src/core/settings.rs @@ -0,0 +1,466 @@ +// Persisted settings. The path, schema, key names and physical-px positions +// stay byte-compatible with the Win32 builds, so no migration step exists. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::core::geom::{ + position_on_any_monitor, Rect, DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE, +}; +use crate::usage::ProviderId; + +pub const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble"; +const SETTINGS_FILE: &str = "settings.json"; + +pub const POLL_1_MIN: u32 = 60_000; +pub const POLL_5_MIN: u32 = 5 * 60_000; +pub const POLL_15_MIN: u32 = 15 * 60_000; +pub const POLL_1_HOUR: u32 = 60 * 60_000; +/// The poll intervals the menu offers, in menu order. +pub const POLL_INTERVALS: [u32; 4] = [POLL_1_MIN, POLL_5_MIN, POLL_15_MIN, POLL_1_HOUR]; + +// Update-check intervals (seconds). `None` means auto-check is disabled. +pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60; +pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60; +pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60; +/// The auto-update check choices the menu offers, in menu order. +pub const UPDATE_CHECK_INTERVALS: [Option; 4] = [ + None, + Some(UPDATE_CHECK_HOURLY_SECS), + Some(UPDATE_CHECK_DAILY_SECS), + Some(UPDATE_CHECK_WEEKLY_SECS), +]; + +fn default_show_claude() -> bool { + true +} +fn default_show_codex() -> bool { + false +} +fn default_show_opencode_go() -> bool { + false +} +fn default_widget_visible() -> bool { + true +} +fn default_bubble_size() -> i32 { + DEFAULT_BUBBLE_SIZE +} +fn default_poll_interval_ms() -> u32 { + POLL_5_MIN +} +fn default_update_check_interval_secs() -> Option { + Some(UPDATE_CHECK_HOURLY_SECS) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BubblePositions { + pub claude: Option<(i32, i32)>, + pub codex: Option<(i32, i32)>, + pub opencode_go: Option<(i32, i32)>, +} + +impl BubblePositions { + pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> { + match provider { + ProviderId::Claude => self.claude, + ProviderId::ChatGpt => self.codex, + ProviderId::OpenCodeGo => self.opencode_go, + } + } + pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) { + match provider { + ProviderId::Claude => self.claude = Some(pos), + ProviderId::ChatGpt => self.codex = Some(pos), + ProviderId::OpenCodeGo => self.opencode_go = Some(pos), + } + } + pub fn reset(&mut self, provider: ProviderId) { + match provider { + ProviderId::Claude => self.claude = None, + ProviderId::ChatGpt => self.codex = None, + ProviderId::OpenCodeGo => self.opencode_go = None, + } + } + pub fn reset_all(&mut self) { + self.claude = None; + self.codex = None; + self.opencode_go = None; + } + + /// Drop any saved position whose top-left no longer falls on one of + /// `monitors`. Guards against creating a bubble on a disconnected + /// secondary monitor, where the user can't see or recover it. + pub fn validate(&mut self, monitors: &[Rect]) { + for provider in ProviderId::ALL { + if let Some((x, y)) = self.get(provider) { + if !position_on_any_monitor(x, y, monitors) { + log::warn!( + "bubble position {} ({x},{y}) outside all monitors; resetting to default", + provider.slug() + ); + self.reset(provider); + } + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + #[serde(default = "default_show_claude")] + pub show_claude_code: bool, + #[serde(default = "default_show_codex")] + pub show_codex: bool, + #[serde(default = "default_show_opencode_go")] + pub show_opencode_go: bool, + #[serde(default)] + pub bubble_positions: BubblePositions, + #[serde(default = "default_bubble_size")] + pub bubble_size_logical: i32, + #[serde(default = "default_poll_interval_ms")] + pub poll_interval_ms: u32, + #[serde(default)] + pub last_update_check_unix: Option, + #[serde(default = "default_update_check_interval_secs")] + pub update_check_interval_secs: Option, + #[serde(default = "default_widget_visible")] + pub widget_visible: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + show_claude_code: default_show_claude(), + show_codex: default_show_codex(), + show_opencode_go: default_show_opencode_go(), + bubble_positions: BubblePositions::default(), + bubble_size_logical: default_bubble_size(), + poll_interval_ms: default_poll_interval_ms(), + last_update_check_unix: None, + update_check_interval_secs: default_update_check_interval_secs(), + widget_visible: default_widget_visible(), + } + } +} + +impl Settings { + pub fn is_provider_enabled(&self, provider: ProviderId) -> bool { + match provider { + ProviderId::Claude => self.show_claude_code, + ProviderId::ChatGpt => self.show_codex, + ProviderId::OpenCodeGo => self.show_opencode_go, + } + } + + pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) { + match provider { + ProviderId::Claude => self.show_claude_code = enabled, + ProviderId::ChatGpt => self.show_codex = enabled, + ProviderId::OpenCodeGo => self.show_opencode_go = enabled, + } + } + + pub fn toggle_provider(&mut self, provider: ProviderId) { + let enabled = !self.is_provider_enabled(provider); + self.set_provider_enabled(provider, enabled); + } + + /// Menu toggle for a provider. Providers without live usage cannot be + /// toggled, and unchecking the last enabled live provider is undone so + /// the app always shows something. Returns the provider's new state. + pub fn toggle_live_provider(&mut self, provider: ProviderId) -> bool { + if provider.metadata().live_usage { + self.toggle_provider(provider); + if !self.has_enabled_live_provider() { + self.set_provider_enabled(provider, true); + } + } + self.is_provider_enabled(provider) + } + + pub fn has_enabled_live_provider(&self) -> bool { + ProviderId::LIVE_USAGE + .iter() + .any(|provider| self.is_provider_enabled(*provider)) + } +} + +pub fn settings_dir() -> Option { + dirs::config_dir().map(|d| d.join(APP_DIR_NAME)) +} + +pub fn settings_path() -> PathBuf { + settings_dir() + .unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME)) + .join(SETTINGS_FILE) +} + +/// Read and normalise the settings file. A missing, unreadable or corrupt +/// file yields the defaults. Saved positions are not checked against the +/// monitors here; call [`drop_offscreen_positions`] once they are known. +pub fn load() -> Settings { + load_from(&settings_path()) +} + +/// [`load`] from an explicit path. +pub fn load_from(path: &Path) -> Settings { + match std::fs::read_to_string(path) { + Ok(content) => parse_and_normalise(&content), + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + log::warn!("settings read failed ({}): {err}", path.display()); + } + Settings::default() + } + } +} + +/// Parse settings JSON, falling back to the defaults when it is corrupt, +/// then enforce the invariants a hand-edited file may break. +pub fn parse_and_normalise(content: &str) -> Settings { + let mut settings: Settings = serde_json::from_str(content).unwrap_or_else(|err| { + log::warn!("settings file is not valid; using defaults: {err}"); + Settings::default() + }); + // At least one live provider must be visible. Otherwise the app has nothing to show. + if !settings.has_enabled_live_provider() { + settings.show_claude_code = true; + } + // Clamp bubble size to safe range in case settings.json was hand-edited. + settings.bubble_size_logical = settings + .bubble_size_logical + .clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); + settings +} + +/// Drop positions on monitors that have since been disconnected. +/// `monitors` are the full monitor rects in physical px. An empty list means +/// the monitors could not be enumerated, so every position is kept rather +/// than all of them being thrown away. +pub fn drop_offscreen_positions(settings: &mut Settings, monitors: &[Rect]) { + if monitors.is_empty() { + log::warn!("no monitors reported; keeping saved bubble positions"); + return; + } + settings.bubble_positions.validate(monitors); +} + +/// Persist `settings` with an atomic write (tmp file, then rename). Falls +/// back to a direct write when the rename fails. +pub fn save(settings: &Settings) -> std::io::Result<()> { + save_to(&settings_path(), settings) +} + +/// [`save`] to an explicit path. +pub fn save_to(path: &Path, settings: &Settings) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(settings).map_err(std::io::Error::other)?; + let tmp_path = path.with_extension("json.tmp"); + let atomic = std::fs::write(&tmp_path, &json).and_then(|()| std::fs::rename(&tmp_path, path)); + match atomic { + Ok(()) => Ok(()), + Err(err) => { + log::warn!("atomic settings save failed, writing directly: {err}"); + let _ = std::fs::remove_file(&tmp_path); + std::fs::write(path, json) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn old_two_provider_settings_json_still_loads() { + let json = r#"{ + "show_claude_code": false, + "show_codex": true, + "bubble_positions": { + "claude": [10, 20], + "codex": [30, 40] + } + }"#; + + let settings: Settings = + serde_json::from_str(json).expect("old settings json should parse"); + + assert!(!settings.show_claude_code); + assert!(settings.show_codex); + assert!(!settings.show_opencode_go); + assert_eq!( + settings.bubble_positions.get(ProviderId::Claude), + Some((10, 20)) + ); + assert_eq!( + settings.bubble_positions.get(ProviderId::ChatGpt), + Some((30, 40)) + ); + assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None); + } + + #[test] + fn three_provider_settings_json_loads() { + let json = r#"{ + "show_claude_code": true, + "show_codex": false, + "show_opencode_go": true, + "bubble_positions": { + "opencode_go": [50, 60] + } + }"#; + + let settings: Settings = + serde_json::from_str(json).expect("new settings json should parse"); + + assert!(settings.show_claude_code); + assert!(!settings.show_codex); + assert!(settings.show_opencode_go); + assert_eq!( + settings.bubble_positions.get(ProviderId::OpenCodeGo), + Some((50, 60)) + ); + } + + #[test] + fn corrupt_file_falls_back_to_defaults() { + let settings = parse_and_normalise("{ not json"); + assert!(settings.show_claude_code); + assert!(!settings.show_codex); + assert_eq!(settings.bubble_size_logical, DEFAULT_BUBBLE_SIZE); + assert_eq!(settings.poll_interval_ms, POLL_5_MIN); + assert_eq!( + settings.update_check_interval_secs, + Some(UPDATE_CHECK_HOURLY_SECS) + ); + assert!(settings.widget_visible); + } + + #[test] + fn normalise_restores_a_live_provider_and_clamps_size() { + let settings = parse_and_normalise( + r#"{"show_claude_code": false, "show_codex": false, "show_opencode_go": true, + "bubble_size_logical": 9000}"#, + ); + assert!(settings.show_claude_code); + assert_eq!(settings.bubble_size_logical, MAX_BUBBLE_SIZE); + let small = parse_and_normalise(r#"{"bubble_size_logical": 10}"#); + assert_eq!(small.bubble_size_logical, MIN_BUBBLE_SIZE); + } + + #[test] + fn serialised_keys_stay_compatible() { + let value = serde_json::to_value(Settings::default()).expect("serialise"); + let mut keys: Vec<_> = value.as_object().expect("object").keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + [ + "bubble_positions", + "bubble_size_logical", + "last_update_check_unix", + "poll_interval_ms", + "show_claude_code", + "show_codex", + "show_opencode_go", + "update_check_interval_secs", + "widget_visible", + ] + ); + let mut s = Settings::default(); + s.bubble_positions.set(ProviderId::ChatGpt, (-1900, 40)); + let json = serde_json::to_string(&s).expect("serialise"); + assert!(json.contains(r#""codex":[-1900,40]"#), "{json}"); + } + + #[test] + fn offscreen_positions_are_dropped_against_monitors() { + let mut settings = parse_and_normalise( + r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100], + "opencode_go": [2000, 500]}}"#, + ); + let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)]; + drop_offscreen_positions(&mut settings, &monitors); + let p = &settings.bubble_positions; + assert_eq!(p.get(ProviderId::Claude), Some((100, 100))); + assert_eq!(p.get(ProviderId::ChatGpt), None); + assert_eq!(p.get(ProviderId::OpenCodeGo), Some((2000, 500))); + } + + #[test] + fn no_monitors_keeps_saved_positions() { + let mut settings = Settings::default(); + settings + .bubble_positions + .set(ProviderId::Claude, (-3000, 100)); + drop_offscreen_positions(&mut settings, &[]); + assert_eq!( + settings.bubble_positions.get(ProviderId::Claude), + Some((-3000, 100)) + ); + } + + #[test] + fn save_then_load_round_trips() { + let dir = std::env::temp_dir().join(format!( + "ccub-settings-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + let path = dir.join(SETTINGS_FILE); + let mut settings = Settings { + show_codex: true, + poll_interval_ms: POLL_15_MIN, + update_check_interval_secs: None, + last_update_check_unix: Some(1_700_000_000), + ..Settings::default() + }; + settings.bubble_positions.set(ProviderId::Claude, (12, 34)); + save_to(&path, &settings).expect("save"); + assert!(!path.with_extension("json.tmp").exists()); + let loaded = load_from(&path); + assert!(loaded.show_codex); + assert_eq!(loaded.poll_interval_ms, POLL_15_MIN); + assert_eq!(loaded.update_check_interval_secs, None); + assert_eq!(loaded.last_update_check_unix, Some(1_700_000_000)); + assert_eq!( + loaded.bubble_positions.get(ProviderId::Claude), + Some((12, 34)) + ); + std::fs::remove_dir_all(&dir).expect("cleanup"); + // A missing file loads the defaults. + assert!(load_from(&path).show_claude_code); + } + + #[test] + fn toggle_keeps_provider_flags_independent() { + let mut settings = Settings::default(); + settings.toggle_provider(ProviderId::ChatGpt); + assert!(settings.is_provider_enabled(ProviderId::ChatGpt)); + settings.toggle_provider(ProviderId::Claude); + assert!(!settings.is_provider_enabled(ProviderId::Claude)); + assert!(settings.has_enabled_live_provider()); + settings.toggle_provider(ProviderId::ChatGpt); + assert!(!settings.has_enabled_live_provider()); + } + + #[test] + fn live_toggle_never_disables_the_last_live_provider() { + let mut settings = Settings::default(); + assert!(settings.toggle_live_provider(ProviderId::Claude)); + assert!(settings.show_claude_code); + assert!(settings.toggle_live_provider(ProviderId::ChatGpt)); + assert!(!settings.toggle_live_provider(ProviderId::Claude)); + assert!(settings.toggle_live_provider(ProviderId::ChatGpt)); + // OpenCode Go has no live usage, so its flag never changes. + assert!(!settings.toggle_live_provider(ProviderId::OpenCodeGo)); + assert!(!settings.show_opencode_go); + } +} diff --git a/src/usage_color.rs b/src/core/usage_color.rs similarity index 61% rename from src/usage_color.rs rename to src/core/usage_color.rs index 74f8c2d..b82525a 100644 --- a/src/usage_color.rs +++ b/src/core/usage_color.rs @@ -2,7 +2,7 @@ // and the tray badge. Keeping the function in one place ensures the three // surfaces never disagree about what "78% used" looks like. -use crate::os::Rgb as Color; +use crate::core::color::Rgb as Color; use crate::usage::ProviderId; /// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks @@ -47,3 +47,37 @@ pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color { Color::from_hex("#A01818") } } + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(model: ProviderId, is_dark: bool, pct: f64) -> String { + bar_fill_color(model, is_dark, pct).to_hex() + } + + #[test] + fn fill_band_edges() { + let c = ProviderId::Claude; + assert_eq!(hex(c, true, 59.9), "#D97757"); + assert_eq!(hex(c, true, 60.0), "#E0A040"); + assert_eq!(hex(c, false, 60.0), "#B47A20"); + assert_eq!(hex(c, true, 79.9), "#E0A040"); + assert_eq!(hex(c, false, 79.9), "#B47A20"); + assert_eq!(hex(c, true, 80.0), "#C45020"); + assert_eq!(hex(c, false, 94.9), "#C45020"); + assert_eq!(hex(c, true, 95.0), "#A01818"); + assert_eq!(hex(c, false, 100.0), "#A01818"); + } + + #[test] + fn safe_band_uses_theme_aware_accent() { + assert_eq!(hex(ProviderId::ChatGpt, true, 10.0), "#E5E5E5"); + assert_eq!(hex(ProviderId::ChatGpt, false, 10.0), "#1A1A1A"); + assert_eq!(hex(ProviderId::OpenCodeGo, false, 0.0), "#3BAE75"); + assert_eq!( + accent_color_for(ProviderId::Claude, false), + accent_color_for(ProviderId::Claude, true) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 442ce95..16fab6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,9 @@ //! 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 core; pub mod diag; +pub mod usage; use tauri::{RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent}; diff --git a/src/os/color.rs b/src/os/color.rs deleted file mode 100644 index 45d8f1f..0000000 --- a/src/os/color.rs +++ /dev/null @@ -1,52 +0,0 @@ -// Color helpers for GDI. -// -// Win32 GDI stores colours as a 32-bit COLORREF in 0x00BBGGRR byte order -// (B in the low byte). We keep a normal `r,g,b` struct in code and convert -// at the FFI boundary. - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Rgb { - pub r: u8, - pub g: u8, - pub b: u8, -} - -impl Rgb { - pub const fn new(r: u8, g: u8, b: u8) -> Self { - Self { r, g, b } - } - - /// Parse `#RRGGBB` or `RRGGBB`. Panics on malformed input — meant for - /// hardcoded design-system literals at compile-known call sites. - pub fn from_hex(hex: &str) -> Self { - Self::parse_hex(hex).unwrap_or_else(|| panic!("invalid hex color literal: {hex:?}")) - } - - /// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input. - pub fn parse_hex(hex: &str) -> Option { - let s = hex.trim_start_matches('#'); - if s.len() != 6 { - return None; - } - let r = u8::from_str_radix(&s[0..2], 16).ok()?; - let g = u8::from_str_radix(&s[2..4], 16).ok()?; - let b = u8::from_str_radix(&s[4..6], 16).ok()?; - Some(Self { r, g, b }) - } - - /// Pack into a Win32 COLORREF (`0x00BBGGRR`). - pub fn into_colorref(self) -> u32 { - (self.r as u32) | ((self.g as u32) << 8) | ((self.b as u32) << 16) - } - - /// Linear interpolation between two colours. `t` is clamped to `[0, 1]`. - pub fn lerp(self, other: Rgb, t: f64) -> Rgb { - let t = t.clamp(0.0, 1.0); - let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8; - Rgb { - r: mix(self.r, other.r), - g: mix(self.g, other.g), - b: mix(self.b, other.b), - } - } -} diff --git a/src/os/dpi.rs b/src/os/dpi.rs deleted file mode 100644 index 8a39afc..0000000 --- a/src/os/dpi.rs +++ /dev/null @@ -1,31 +0,0 @@ -// Per-window DPI helpers. -// -// The DPI of a window can differ from `GetDpiForSystem` on multi-monitor -// setups where the app is per-monitor DPI aware. Always prefer -// `GetDpiForWindow` for HWNDs that participate in the message loop. - -use windows::Win32::Foundation::HWND; -use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetDpiForWindow}; - -/// The default DPI Win32 reports for 100% scaling. -pub const BASE_DPI: u32 = 96; - -/// DPI for the supplied window. Falls back to system DPI if the call fails. -pub fn for_window(hwnd: HWND) -> u32 { - let raw = unsafe { GetDpiForWindow(hwnd) }; - if raw == 0 { - for_system() - } else { - raw.max(BASE_DPI) - } -} - -/// Global system DPI. Cheap; safe to call from any thread. -pub fn for_system() -> u32 { - unsafe { GetDpiForSystem() }.max(BASE_DPI) -} - -/// Scale a logical (96-DPI) pixel measurement to the given DPI. -pub fn scale(logical_px: i32, dpi: u32) -> i32 { - ((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32 -} diff --git a/src/settings.rs b/src/settings.rs deleted file mode 100644 index 2db18d7..0000000 --- a/src/settings.rs +++ /dev/null @@ -1,295 +0,0 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use windows::Win32::Foundation::RECT; -use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL}; - -use crate::bubble::DEFAULT_BUBBLE_SIZE; -use crate::usage::ProviderId; - -// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area -// edge still passes the validator, but a position fully on a disconnected -// monitor (the bug we're guarding against) fails. -const POSITION_PROBE_PX: i32 = 140; - -const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble"; -const SETTINGS_FILE: &str = "settings.json"; - -pub const POLL_1_MIN: u32 = 60_000; -pub const POLL_5_MIN: u32 = 5 * 60_000; -pub const POLL_15_MIN: u32 = 15 * 60_000; -pub const POLL_1_HOUR: u32 = 60 * 60_000; - -// Update-check intervals (seconds). `None` means auto-check is disabled. -pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60; -pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60; -pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60; - -fn default_show_claude() -> bool { - true -} -fn default_show_codex() -> bool { - false -} -fn default_show_opencode_go() -> bool { - false -} -fn default_widget_visible() -> bool { - true -} -fn default_bubble_size() -> i32 { - DEFAULT_BUBBLE_SIZE -} -fn default_poll_interval_ms() -> u32 { - POLL_5_MIN -} -fn default_update_check_interval_secs() -> Option { - Some(UPDATE_CHECK_HOURLY_SECS) -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct BubblePositions { - pub claude: Option<(i32, i32)>, - pub codex: Option<(i32, i32)>, - pub opencode_go: Option<(i32, i32)>, -} - -impl BubblePositions { - pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> { - match provider { - ProviderId::Claude => self.claude, - ProviderId::ChatGpt => self.codex, - ProviderId::OpenCodeGo => self.opencode_go, - } - } - pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) { - match provider { - ProviderId::Claude => self.claude = Some(pos), - ProviderId::ChatGpt => self.codex = Some(pos), - ProviderId::OpenCodeGo => self.opencode_go = Some(pos), - } - } - pub fn reset(&mut self, provider: ProviderId) { - match provider { - ProviderId::Claude => self.claude = None, - ProviderId::ChatGpt => self.codex = None, - ProviderId::OpenCodeGo => self.opencode_go = None, - } - } - pub fn reset_all(&mut self) { - self.claude = None; - self.codex = None; - self.opencode_go = None; - } - - /// Drop any saved position whose top-left no longer falls on a connected - /// monitor. Guards against `bubble::create` placing the window on a - /// disconnected secondary monitor (where the user can't see or recover it). - pub fn validate(&mut self) { - if let Some((x, y)) = self.claude { - if !position_on_any_monitor(x, y) { - log::warn!( - "bubble position claude ({x},{y}) outside all monitors; resetting to default" - ); - self.claude = None; - } - } - if let Some((x, y)) = self.codex { - if !position_on_any_monitor(x, y) { - log::warn!( - "bubble position codex ({x},{y}) outside all monitors; resetting to default" - ); - self.codex = None; - } - } - if let Some((x, y)) = self.opencode_go { - if !position_on_any_monitor(x, y) { - log::warn!("bubble position opencode-go ({x},{y}) outside all monitors; resetting to default"); - self.opencode_go = None; - } - } - } -} - -fn position_on_any_monitor(x: i32, y: i32) -> bool { - // MONITOR_DEFAULTTONULL returns a null HMONITOR when the rect intersects - // no connected monitor — exactly the signal we want. - let probe = RECT { - left: x, - top: y, - right: x + POSITION_PROBE_PX, - bottom: y + POSITION_PROBE_PX, - }; - let monitor = unsafe { MonitorFromRect(&probe, MONITOR_DEFAULTTONULL) }; - !monitor.is_invalid() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { - #[serde(default = "default_show_claude")] - pub show_claude_code: bool, - #[serde(default = "default_show_codex")] - pub show_codex: bool, - #[serde(default = "default_show_opencode_go")] - pub show_opencode_go: bool, - #[serde(default)] - pub bubble_positions: BubblePositions, - #[serde(default = "default_bubble_size")] - pub bubble_size_logical: i32, - #[serde(default = "default_poll_interval_ms")] - pub poll_interval_ms: u32, - #[serde(default)] - pub last_update_check_unix: Option, - #[serde(default = "default_update_check_interval_secs")] - pub update_check_interval_secs: Option, - #[serde(default = "default_widget_visible")] - pub widget_visible: bool, -} - -impl Default for Settings { - fn default() -> Self { - Self { - show_claude_code: default_show_claude(), - show_codex: default_show_codex(), - show_opencode_go: default_show_opencode_go(), - bubble_positions: BubblePositions::default(), - bubble_size_logical: default_bubble_size(), - poll_interval_ms: default_poll_interval_ms(), - last_update_check_unix: None, - update_check_interval_secs: default_update_check_interval_secs(), - widget_visible: default_widget_visible(), - } - } -} - -impl Settings { - pub fn is_provider_enabled(&self, provider: ProviderId) -> bool { - match provider { - ProviderId::Claude => self.show_claude_code, - ProviderId::ChatGpt => self.show_codex, - ProviderId::OpenCodeGo => self.show_opencode_go, - } - } - - pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) { - match provider { - ProviderId::Claude => self.show_claude_code = enabled, - ProviderId::ChatGpt => self.show_codex = enabled, - ProviderId::OpenCodeGo => self.show_opencode_go = enabled, - } - } - - pub fn toggle_provider(&mut self, provider: ProviderId) { - let enabled = !self.is_provider_enabled(provider); - self.set_provider_enabled(provider, enabled); - } - - pub fn has_enabled_live_provider(&self) -> bool { - ProviderId::LIVE_USAGE - .iter() - .any(|provider| self.is_provider_enabled(*provider)) - } -} - -pub fn settings_dir() -> Option { - dirs::config_dir().map(|d| d.join(APP_DIR_NAME)) -} - -pub fn settings_path() -> PathBuf { - settings_dir() - .unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME)) - .join(SETTINGS_FILE) -} - -pub fn load() -> Settings { - let path = settings_path(); - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return Settings::default(), - }; - let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default(); - // At least one live provider must be visible. Otherwise the app has nothing to show. - if !settings.has_enabled_live_provider() { - settings.show_claude_code = true; - } - // Clamp bubble size to safe range in case settings.json was hand-edited. - settings.bubble_size_logical = settings.bubble_size_logical.clamp( - crate::bubble::MIN_BUBBLE_SIZE, - crate::bubble::MAX_BUBBLE_SIZE, - ); - // Drop positions on monitors that have since been disconnected. - settings.bubble_positions.validate(); - settings -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn old_two_provider_settings_json_still_loads() { - let json = r#"{ - "show_claude_code": false, - "show_codex": true, - "bubble_positions": { - "claude": [10, 20], - "codex": [30, 40] - } - }"#; - - let settings: Settings = - serde_json::from_str(json).expect("old settings json should parse"); - - assert!(!settings.show_claude_code); - assert!(settings.show_codex); - assert!(!settings.show_opencode_go); - assert_eq!( - settings.bubble_positions.get(ProviderId::Claude), - Some((10, 20)) - ); - assert_eq!( - settings.bubble_positions.get(ProviderId::ChatGpt), - Some((30, 40)) - ); - assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None); - } - - #[test] - fn three_provider_settings_json_loads() { - let json = r#"{ - "show_claude_code": true, - "show_codex": false, - "show_opencode_go": true, - "bubble_positions": { - "opencode_go": [50, 60] - } - }"#; - - let settings: Settings = - serde_json::from_str(json).expect("new settings json should parse"); - - assert!(settings.show_claude_code); - assert!(!settings.show_codex); - assert!(settings.show_opencode_go); - assert_eq!( - settings.bubble_positions.get(ProviderId::OpenCodeGo), - Some((50, 60)) - ); - } -} - -pub fn save(settings: &Settings) { - let path = settings_path(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let Ok(json) = serde_json::to_string_pretty(settings) else { - return; - }; - // Atomic write: tmp then rename. Falls back to direct write on rename failure. - let tmp_path = path.with_extension("json.tmp"); - if std::fs::write(&tmp_path, &json).is_ok() && std::fs::rename(&tmp_path, &path).is_ok() { - return; - } - let _ = std::fs::write(&path, json); -} diff --git a/src/usage/mod.rs b/src/usage/mod.rs index 4f7257a..c25cbb2 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -1,38 +1,7 @@ -// Usage subsystem: trait + per-provider implementations + registry. -// -// Phase 2 stubs out only `types`. Phase 4 fills in `anthropic`, `chatgpt`, -// `refresh`, `registry`, and `headers`. The trait below is the contract -// every provider must satisfy. +// Usage subsystem. Only the shared data shapes are compiled for now; the +// providers, registry, headers and refresh orchestrator return with the +// HTTP client port. -pub mod anthropic; -pub mod chatgpt; -pub mod headers; -pub mod refresh; -pub mod registry; pub mod types; -pub use registry::Registry; pub use types::{ProviderId, UsageWindows, Window}; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("authentication required")] - AuthRequired, - #[error("no credentials configured")] - NoCredentials, - #[error("token expired after refresh attempt")] - TokenExpired, - #[error("network: {0}")] - Network(#[from] crate::net::Error), - #[error("credentials: {0}")] - Creds(#[from] crate::creds::Error), - #[error("unexpected response shape: {0}")] - BadResponse(String), -} - -/// Every provider exposes a stable identity and a sync `poll` that performs -/// HTTP calls against the supplied client. -pub trait UsageProvider: Send { - fn id(&self) -> ProviderId; - fn poll(&mut self, http: &crate::net::Client) -> Result; -} diff --git a/src/usage/types.rs b/src/usage/types.rs index 223b985..3e54ff8 100644 --- a/src/usage/types.rs +++ b/src/usage/types.rs @@ -2,10 +2,17 @@ use std::time::SystemTime; -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +use serde::Serialize; + +/// Serialises as its [`slug`](ProviderId::slug), which also names the +/// provider's bubble window and tray icon. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize)] pub enum ProviderId { + #[serde(rename = "claude")] Claude, + #[serde(rename = "chatgpt")] ChatGpt, + #[serde(rename = "opencode-go")] OpenCodeGo, } @@ -21,6 +28,11 @@ impl ProviderId { } } + /// Inverse of [`slug`](ProviderId::slug). + pub fn from_slug(slug: &str) -> Option { + Self::ALL.into_iter().find(|p| p.slug() == slug) + } + pub fn metadata(self) -> ProviderMetadata { match self { Self::Claude => ProviderMetadata { @@ -87,3 +99,19 @@ pub struct ProviderSnapshot { pub id: ProviderId, pub windows: UsageWindows, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialises_as_slug_and_parses_back() { + for provider in ProviderId::ALL { + let json = serde_json::to_string(&provider).expect("serialise"); + assert_eq!(json, format!("\"{}\"", provider.slug())); + assert_eq!(ProviderId::from_slug(provider.slug()), Some(provider)); + assert_eq!(provider.metadata().slug, provider.slug()); + } + assert_eq!(ProviderId::from_slug("codex"), None); + } +}