mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 04:14:52 +00:00
refactor: move settings, strings, colours and geometry into a portable core
Settings, the English strings, the colour ramp and the bubble geometry no longer touch Win32 types, so they compile and test on every platform. Snapping, clamping, default placement, panel placement and the monitor probe take plain rects; settings loading is split into parsing and an offscreen check fed with the monitor list. ProviderId now serialises as its slug.
This commit is contained in:
1 parent
d8f339208d
commit
bc1a098875
12 files changed
+1573
-418
No files matched your search
@@ -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<Self> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<Rect>,
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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<f64>) -> 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<SystemTime>, strings: &LocaleStrings) -> String {
|
||||
format_countdown_at(resets_at, strings, SystemTime::now())
|
||||
}
|
||||
|
||||
/// [`format_countdown`] evaluated at `now`.
|
||||
pub fn format_countdown_at(
|
||||
resets_at: Option<SystemTime>,
|
||||
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<SystemTime>) -> Option<Duration> {
|
||||
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<SystemTime>,
|
||||
now: SystemTime,
|
||||
) -> Option<Duration> {
|
||||
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<SystemTime> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<u64>; 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<u64> {
|
||||
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<u64>,
|
||||
#[serde(default = "default_update_check_interval_secs")]
|
||||
pub update_check_interval_secs: Option<u64>,
|
||||
#[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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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<Self> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
-295
@@ -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<u64> {
|
||||
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<u64>,
|
||||
#[serde(default = "default_update_check_interval_secs")]
|
||||
pub update_check_interval_secs: Option<u64>,
|
||||
#[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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
+3
-34
@@ -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<UsageWindows, Error>;
|
||||
}
|
||||
+29
-1
@@ -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> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user