diff --git a/src/core/menu_model.rs b/src/core/menu_model.rs new file mode 100644 index 0000000..2fe094e --- /dev/null +++ b/src/core/menu_model.rs @@ -0,0 +1,453 @@ +//! The context menu as a plain tree with string ids, independent of any +//! menu toolkit. The tray and bubble menus are built from it, and menu +//! events come back as [`MenuCommand::parse`] of the clicked id. + +use crate::core::geom::{DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE}; +use crate::core::i18n::LocaleStrings; +use crate::core::settings::{Settings, POLL_INTERVALS, UPDATE_CHECK_INTERVALS}; +use crate::core::state::{version_action_label, UpdateStatus}; +use crate::usage::ProviderId; + +/// Every action a menu item can trigger. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuCommand { + Refresh, + PollInterval(u32), + ToggleProvider(ProviderId), + Autostart, + ResetPosition, + VersionAction, + /// `None` disables the automatic check. + UpdateInterval(Option), + SizeSmaller, + SizeLarger, + SizeReset, + ToggleWidget, + Restart, + Exit, +} + +impl MenuCommand { + /// The item id: `refresh`, `freq:`, `provider:`, `autostart`, + /// `reset-position`, `version`, `update-interval:`, + /// `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`, + /// `restart` or `exit`. + pub fn id(self) -> String { + match self { + MenuCommand::Refresh => "refresh".into(), + MenuCommand::PollInterval(ms) => format!("freq:{ms}"), + MenuCommand::ToggleProvider(p) => format!("provider:{}", p.slug()), + MenuCommand::Autostart => "autostart".into(), + MenuCommand::ResetPosition => "reset-position".into(), + MenuCommand::VersionAction => "version".into(), + MenuCommand::UpdateInterval(None) => "update-interval:off".into(), + MenuCommand::UpdateInterval(Some(secs)) => format!("update-interval:{secs}"), + MenuCommand::SizeSmaller => "size-smaller".into(), + MenuCommand::SizeLarger => "size-larger".into(), + MenuCommand::SizeReset => "size-reset".into(), + MenuCommand::ToggleWidget => "toggle-widget".into(), + MenuCommand::Restart => "restart".into(), + MenuCommand::Exit => "exit".into(), + } + } + + /// Parse an item id. Only values the menu offers are accepted, so an + /// unknown or hand-made id maps to `None` instead of an arbitrary + /// interval. + pub fn parse(id: &str) -> Option { + let simple = match id { + "refresh" => Some(MenuCommand::Refresh), + "autostart" => Some(MenuCommand::Autostart), + "reset-position" => Some(MenuCommand::ResetPosition), + "version" => Some(MenuCommand::VersionAction), + "size-smaller" => Some(MenuCommand::SizeSmaller), + "size-larger" => Some(MenuCommand::SizeLarger), + "size-reset" => Some(MenuCommand::SizeReset), + "toggle-widget" => Some(MenuCommand::ToggleWidget), + "restart" => Some(MenuCommand::Restart), + "exit" => Some(MenuCommand::Exit), + _ => None, + }; + if simple.is_some() { + return simple; + } + let (kind, value) = id.split_once(':')?; + match kind { + "freq" => { + let ms = value.parse::().ok()?; + POLL_INTERVALS + .contains(&ms) + .then_some(MenuCommand::PollInterval(ms)) + } + "provider" => ProviderId::from_slug(value).map(MenuCommand::ToggleProvider), + "update-interval" => { + let secs = if value == "off" { + None + } else { + Some(value.parse::().ok()?) + }; + UPDATE_CHECK_INTERVALS + .contains(&secs) + .then_some(MenuCommand::UpdateInterval(secs)) + } + _ => None, + } + } +} + +/// One node of the menu tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MenuEntry { + Action { + command: MenuCommand, + label: String, + enabled: bool, + }, + Check { + command: MenuCommand, + label: String, + checked: bool, + enabled: bool, + }, + /// A greyed, id-less hint row. + Hint { + label: String, + }, + Separator, + Submenu { + label: String, + entries: Vec, + }, +} + +/// Everything the menu reflects, snapshotted outside the state lock. +#[derive(Clone, Copy, Debug)] +pub struct MenuInput<'a> { + pub settings: &'a Settings, + pub strings: &'a LocaleStrings, + pub autostart_enabled: bool, + pub update_status: UpdateStatus, +} + +fn action(command: MenuCommand, label: &str, enabled: bool) -> MenuEntry { + MenuEntry::Action { + command, + label: label.to_string(), + enabled, + } +} + +fn check(command: MenuCommand, label: &str, checked: bool, enabled: bool) -> MenuEntry { + MenuEntry::Check { + command, + label: label.to_string(), + checked, + enabled, + } +} + +fn hint(label: &str) -> MenuEntry { + MenuEntry::Hint { + label: label.to_string(), + } +} + +fn submenu(label: &str, entries: Vec) -> MenuEntry { + MenuEntry::Submenu { + label: label.to_string(), + entries, + } +} + +/// Build the context menu in display order. +pub fn build(input: MenuInput<'_>) -> Vec { + let MenuInput { + settings, + strings: s, + autostart_enabled, + update_status, + } = input; + + let freq_labels = [ + &s.one_minute, + &s.five_minutes, + &s.fifteen_minutes, + &s.one_hour, + ]; + let frequency = POLL_INTERVALS + .iter() + .zip(freq_labels) + .map(|(&ms, label)| { + check( + MenuCommand::PollInterval(ms), + label, + settings.poll_interval_ms == ms, + true, + ) + }) + .collect(); + + let providers = ProviderId::ALL + .iter() + .map(|&p| { + check( + MenuCommand::ToggleProvider(p), + s.provider_label(p), + settings.is_provider_enabled(p), + p.metadata().live_usage, + ) + }) + .collect(); + + let auto_labels = [ + &s.auto_check_disabled, + &s.auto_check_hourly, + &s.auto_check_daily, + &s.auto_check_weekly, + ]; + let auto_update = UPDATE_CHECK_INTERVALS + .iter() + .zip(auto_labels) + .map(|(&secs, label)| { + check( + MenuCommand::UpdateInterval(secs), + label, + settings.update_check_interval_secs == secs, + true, + ) + }) + .collect(); + + let size = settings.bubble_size_logical; + let settings_menu = vec![ + check( + MenuCommand::Autostart, + s.autostart_label(), + autostart_enabled, + true, + ), + action(MenuCommand::ResetPosition, &s.reset_position, true), + action( + MenuCommand::VersionAction, + &version_action_label(update_status, s), + !update_status.is_busy(), + ), + submenu(&s.auto_update_check, auto_update), + ]; + + let controls = vec![ + action( + MenuCommand::SizeSmaller, + &s.size_smaller, + size > MIN_BUBBLE_SIZE, + ), + action( + MenuCommand::SizeLarger, + &s.size_larger, + size < MAX_BUBBLE_SIZE, + ), + action( + MenuCommand::SizeReset, + &s.reset_size, + size != DEFAULT_BUBBLE_SIZE, + ), + MenuEntry::Separator, + hint(&s.control_left_click), + hint(&s.control_right_click), + hint(&s.control_drag), + hint(&s.control_ctrl_wheel), + hint(&s.control_tray_click), + ]; + + vec![ + action(MenuCommand::Refresh, &s.refresh, true), + submenu(&s.update_frequency, frequency), + submenu(&s.providers, providers), + submenu(&s.settings, settings_menu), + submenu(&s.controls, controls), + check( + MenuCommand::ToggleWidget, + &s.show_widget, + settings.widget_visible, + true, + ), + MenuEntry::Separator, + action(MenuCommand::Restart, &s.restart, true), + action(MenuCommand::Exit, &s.exit, true), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::i18n::I18n; + use crate::core::settings::{POLL_15_MIN, UPDATE_CHECK_DAILY_SECS}; + + fn menu(settings: &Settings, status: UpdateStatus) -> Vec { + let strings = I18n::load().strings().clone(); + build(MenuInput { + settings, + strings: &strings, + autostart_enabled: true, + update_status: status, + }) + } + + fn find<'a>(entries: &'a [MenuEntry], id: &str) -> Option<&'a MenuEntry> { + entries.iter().find_map(|e| match e { + MenuEntry::Action { command, .. } | MenuEntry::Check { command, .. } + if command.id() == id => + { + Some(e) + } + MenuEntry::Submenu { entries, .. } => find(entries, id), + _ => None, + }) + } + + fn enabled(entries: &[MenuEntry], id: &str) -> bool { + match find(entries, id) { + Some(MenuEntry::Action { enabled, .. } | MenuEntry::Check { enabled, .. }) => *enabled, + other => panic!("no item {id}: {other:?}"), + } + } + + fn checked(entries: &[MenuEntry], id: &str) -> bool { + match find(entries, id) { + Some(MenuEntry::Check { checked, .. }) => *checked, + other => panic!("no check item {id}: {other:?}"), + } + } + + fn with_size(size: i32) -> Settings { + Settings { + bubble_size_logical: size, + ..Settings::default() + } + } + + #[test] + fn size_items_grey_out_at_the_limits() { + let m = menu(&with_size(140), UpdateStatus::Idle); + assert!(!enabled(&m, "size-smaller")); + assert!(enabled(&m, "size-larger")); + assert!(enabled(&m, "size-reset")); + let m = menu(&with_size(360), UpdateStatus::Idle); + assert!(enabled(&m, "size-smaller")); + assert!(!enabled(&m, "size-larger")); + assert!(enabled(&m, "size-reset")); + let m = menu(&with_size(200), UpdateStatus::Idle); + assert!(enabled(&m, "size-smaller")); + assert!(enabled(&m, "size-larger")); + assert!(!enabled(&m, "size-reset")); + } + + #[test] + fn checks_follow_settings() { + let settings = Settings { + poll_interval_ms: POLL_15_MIN, + update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS), + show_codex: true, + show_opencode_go: true, + widget_visible: false, + ..Settings::default() + }; + let m = menu(&settings, UpdateStatus::Idle); + assert!(checked(&m, "freq:900000")); + assert!(!checked(&m, "freq:300000")); + assert!(checked(&m, "update-interval:86400")); + assert!(!checked(&m, "update-interval:off")); + assert!(checked(&m, "provider:claude")); + assert!(checked(&m, "provider:chatgpt")); + // OpenCode Go mirrors its flag but stays greyed. + assert!(checked(&m, "provider:opencode-go")); + assert!(!enabled(&m, "provider:opencode-go")); + assert!(checked(&m, "autostart")); + assert!(!checked(&m, "toggle-widget")); + } + + #[test] + fn version_item_is_disabled_while_busy() { + assert!(!enabled( + &menu(&Settings::default(), UpdateStatus::Checking), + "version" + )); + assert!(!enabled( + &menu(&Settings::default(), UpdateStatus::Applying), + "version" + )); + assert!(enabled( + &menu(&Settings::default(), UpdateStatus::Available), + "version" + )); + } + + #[test] + fn top_level_order_and_hint_rows() { + let m = menu(&Settings::default(), UpdateStatus::Idle); + let labels: Vec = m + .iter() + .map(|e| match e { + MenuEntry::Action { label, .. } + | MenuEntry::Check { label, .. } + | MenuEntry::Hint { label } + | MenuEntry::Submenu { label, .. } => label.clone(), + MenuEntry::Separator => "-".into(), + }) + .collect(); + assert_eq!( + labels, + [ + "Refresh", + "Update frequency", + "Providers", + "Settings", + "Controls", + "Show widget", + "-", + "Restart", + "Exit" + ] + ); + let Some(MenuEntry::Submenu { entries, .. }) = m.get(4) else { + panic!("controls submenu missing"); + }; + assert_eq!(entries.len(), 9); + assert_eq!(entries[3], MenuEntry::Separator); + assert!(entries[4..] + .iter() + .all(|e| matches!(e, MenuEntry::Hint { .. }))); + } + + #[test] + fn ids_round_trip_and_unknown_ids_are_rejected() { + let m = menu(&Settings::default(), UpdateStatus::Idle); + fn walk(entries: &[MenuEntry], out: &mut Vec) { + for e in entries { + match e { + MenuEntry::Action { command, .. } | MenuEntry::Check { command, .. } => { + out.push(*command) + } + MenuEntry::Submenu { entries, .. } => walk(entries, out), + _ => {} + } + } + } + let mut commands = Vec::new(); + walk(&m, &mut commands); + assert_eq!(commands.len(), 21); + for c in commands { + assert_eq!(MenuCommand::parse(&c.id()), Some(c), "{}", c.id()); + } + for bad in [ + "", + "freq:1", + "freq:abc", + "provider:codex", + "update-interval:5", + "nope:1", + ] { + assert_eq!(MenuCommand::parse(bad), None, "{bad}"); + } + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 9872937..842360a 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -3,5 +3,8 @@ pub mod color; pub mod geom; pub mod i18n; +pub mod menu_model; +pub mod payload; pub mod settings; +pub mod state; pub mod usage_color; diff --git a/src/core/payload.rs b/src/core/payload.rs new file mode 100644 index 0000000..971d503 --- /dev/null +++ b/src/core/payload.rs @@ -0,0 +1,347 @@ +//! Pure builders for the JSON payloads the bubble and panel webviews render. +//! +//! Rust owns every displayed string and colour; the webview only lays out +//! pixels. Field names are camelCase on the wire. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +use crate::core::i18n::{self, LocaleStrings}; +use crate::core::state::ProviderUiState; +use crate::core::usage_color::{accent_color_for, bar_fill_color}; +use crate::usage::ProviderId; + +const FIVE_HOURS_SECS: u64 = 5 * 60 * 60; +const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60; +const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60; + +/// Placeholder for the head label and weekly countdown before the first poll. +const PENDING: &str = "…"; + +/// Length of a provider's `(primary, secondary)` usage windows in seconds. +/// Claude exposes 5h/7d directly; Codex's primary/secondary map to the same +/// short/long windows in the compact UI. +pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) { + match provider { + ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS), + ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS), + } +} + +/// The bubble window's canvas, in physical px. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Canvas { + pub w: u32, + pub h: u32, + pub dpi: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BubblePayload { + pub provider: ProviderId, + pub session_pct: Option, + pub weekly_pct: Option, + /// `"73%"`, or `"—"` with no value. + pub session_pct_text: String, + /// `None` hides the weekly percent. + pub weekly_pct_text: Option, + /// Session countdown; `"…"` before the first poll. The UI swaps an empty + /// or overflowing label for `"5H"`. + pub head_label: String, + /// Weekly countdown; `"…"` before the first poll, `""` hides it. + pub weekly_countdown: String, + pub session_resets_at_ms: Option, + pub weekly_resets_at_ms: Option, + pub primary_dur_secs: u64, + pub secondary_dur_secs: u64, + pub session_fill: String, + pub weekly_fill: String, + pub accent: String, + pub is_dark: bool, + pub canvas_w: u32, + pub canvas_h: u32, + pub dpi: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelPayload { + pub provider: ProviderId, + pub label: String, + pub is_dark: bool, + pub accent: String, + pub session_pct: f64, + pub weekly_pct: f64, + /// `"73% · 2h"`, `"!"` after an auth failure, `"…"` after a transient + /// error, `""` before the first poll. + pub session_text: String, + pub weekly_text: String, + pub session_fill: String, + pub weekly_fill: String, +} + +fn unix_ms(t: Option) -> Option { + let ms = t?.duration_since(UNIX_EPOCH).ok()?.as_millis(); + u64::try_from(ms).ok() +} + +/// Build the bubble payload. `snapshot` is `None` before the provider's +/// first poll. After an error the snapshot still holds the last good +/// windows, so the bubble keeps those values and countdowns; it never shows +/// the panel's `"!"` or `"…"` markers. +pub fn bubble_payload( + provider: ProviderId, + snapshot: Option<&ProviderUiState>, + strings: &LocaleStrings, + is_dark: bool, + canvas: Canvas, + now: SystemTime, +) -> BubblePayload { + let windows = snapshot.map(|s| s.windows); + let session_pct = windows.map(|w| w.primary.utilization); + let weekly_pct = windows.map(|w| w.secondary.utilization); + let (head_label, weekly_countdown) = match windows { + Some(w) => ( + i18n::format_countdown_at(w.primary.resets_at, strings, now), + i18n::format_countdown_at(w.secondary.resets_at, strings, now), + ), + None => (PENDING.to_string(), PENDING.to_string()), + }; + let (primary_dur_secs, secondary_dur_secs) = window_durations_secs(provider); + BubblePayload { + provider, + session_pct, + weekly_pct, + session_pct_text: i18n::format_pct(session_pct), + weekly_pct_text: weekly_pct.map(|p| i18n::format_pct(Some(p))), + head_label, + weekly_countdown, + session_resets_at_ms: unix_ms(windows.and_then(|w| w.primary.resets_at)), + weekly_resets_at_ms: unix_ms(windows.and_then(|w| w.secondary.resets_at)), + primary_dur_secs, + secondary_dur_secs, + session_fill: bar_fill_color(provider, is_dark, session_pct.unwrap_or(0.0)).to_hex(), + weekly_fill: bar_fill_color(provider, is_dark, weekly_pct.unwrap_or(0.0)).to_hex(), + accent: accent_color_for(provider, is_dark).to_hex(), + is_dark, + canvas_w: canvas.w, + canvas_h: canvas.h, + dpi: canvas.dpi, + } +} + +/// Build the details-panel payload. Before the first poll the bars are +/// empty and the texts blank. +pub fn panel_payload( + provider: ProviderId, + snapshot: Option<&ProviderUiState>, + strings: &LocaleStrings, + is_dark: bool, +) -> PanelPayload { + let state = snapshot.cloned().unwrap_or_default(); + let session_pct = state.windows.primary.utilization; + let weekly_pct = state.windows.secondary.utilization; + PanelPayload { + provider, + label: strings.provider_label(provider).to_string(), + is_dark, + accent: accent_color_for(provider, is_dark).to_hex(), + session_pct, + weekly_pct, + session_text: state.primary_text, + weekly_text: state.secondary_text, + session_fill: bar_fill_color(provider, is_dark, session_pct).to_hex(), + weekly_fill: bar_fill_color(provider, is_dark, weekly_pct).to_hex(), + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::*; + use crate::core::i18n::I18n; + use crate::core::state::{PollOutcome, UsageState}; + use crate::usage::{UsageWindows, Window}; + + const CANVAS: Canvas = Canvas { + w: 300, + h: 99, + dpi: 144, + }; + + fn now() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(1_800_000_000) + } + + fn strings() -> LocaleStrings { + I18n::load().strings().clone() + } + + fn polled(session: f64, weekly: f64) -> UsageState { + let mut state = UsageState::default(); + let windows = UsageWindows { + primary: Window { + utilization: session, + resets_at: Some(now() + Duration::from_secs(2 * 3_600 + 10)), + }, + secondary: Window { + utilization: weekly, + resets_at: Some(now() + Duration::from_secs(3 * 86_400)), + }, + }; + state.apply_results( + vec![(ProviderId::Claude, PollOutcome::Ok(windows))], + &strings(), + Instant::now(), + ); + state + } + + #[test] + fn before_first_poll_shows_placeholders() { + let p = bubble_payload(ProviderId::ChatGpt, None, &strings(), true, CANVAS, now()); + assert_eq!(p.session_pct, None); + assert_eq!(p.session_pct_text, "—"); + assert_eq!(p.weekly_pct_text, None); + assert_eq!(p.head_label, "…"); + assert_eq!(p.weekly_countdown, "…"); + assert_eq!(p.session_resets_at_ms, None); + assert_eq!(p.session_fill, "#E5E5E5"); + assert_eq!(p.accent, "#E5E5E5"); + assert_eq!((p.canvas_w, p.canvas_h, p.dpi), (300, 99, 144)); + + let panel = panel_payload(ProviderId::ChatGpt, None, &strings(), false); + assert_eq!(panel.session_text, ""); + assert_eq!(panel.weekly_text, ""); + assert_eq!(panel.session_pct, 0.0); + assert_eq!(panel.label, "Codex"); + assert_eq!(panel.accent, "#1A1A1A"); + } + + #[test] + fn polled_values_format_in_rust() { + let state = polled(72.5, 96.0); + let snap = state.snapshots.get(&ProviderId::Claude); + let p = bubble_payload(ProviderId::Claude, snap, &strings(), true, CANVAS, now()); + assert_eq!(p.session_pct, Some(72.5)); + assert_eq!(p.session_pct_text, "72%"); + assert_eq!(p.weekly_pct_text.as_deref(), Some("96%")); + assert_eq!(p.head_label, "2h"); + assert_eq!(p.weekly_countdown, "3d"); + assert_eq!( + p.session_resets_at_ms, + Some((1_800_000_000 + 2 * 3_600 + 10) * 1_000) + ); + assert_eq!( + (p.primary_dur_secs, p.secondary_dur_secs), + (18_000, 604_800) + ); + assert_eq!(p.session_fill, "#E0A040"); + assert_eq!(p.weekly_fill, "#A01818"); + assert_eq!(p.accent, "#D97757"); + } + + #[test] + fn errors_keep_stale_bubble_values_but_mark_the_panel() { + let mut state = polled(40.0, 20.0); + state.apply_results( + vec![(ProviderId::Claude, PollOutcome::AuthFailed)], + &strings(), + Instant::now(), + ); + let snap = state.snapshots.get(&ProviderId::Claude); + let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now()); + assert_eq!(p.session_pct_text, "40%"); + assert_eq!(p.head_label, "2h"); + assert_eq!(p.weekly_countdown, "3d"); + let json = serde_json::to_string(&p).expect("serialise"); + assert!(!json.contains('!'), "{json}"); + + let panel = panel_payload(ProviderId::Claude, snap, &strings(), false); + assert_eq!(panel.session_text, "!"); + assert_eq!(panel.weekly_text, "!"); + assert_eq!(panel.session_pct, 40.0); + + state.apply_results( + vec![(ProviderId::Claude, PollOutcome::Failed)], + &strings(), + Instant::now(), + ); + let snap = state.snapshots.get(&ProviderId::Claude); + let panel = panel_payload(ProviderId::Claude, snap, &strings(), false); + assert_eq!(panel.session_text, "…"); + assert_eq!(panel.weekly_text, "…"); + let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now()); + assert_eq!(p.head_label, "2h"); + } + + #[test] + fn wire_format_is_camel_case_with_nulls() { + let p = bubble_payload(ProviderId::Claude, None, &strings(), true, CANVAS, now()); + let v = serde_json::to_value(&p).expect("serialise"); + assert_eq!(v["provider"], "claude"); + assert!(v["sessionPct"].is_null()); + assert!(v["weeklyPctText"].is_null()); + assert_eq!(v["sessionPctText"], "—"); + assert_eq!(v["canvasW"], 300); + assert_eq!(v["isDark"], true); + let mut keys: Vec<&str> = v + .as_object() + .expect("object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "accent", + "canvasH", + "canvasW", + "dpi", + "headLabel", + "isDark", + "primaryDurSecs", + "provider", + "secondaryDurSecs", + "sessionFill", + "sessionPct", + "sessionPctText", + "sessionResetsAtMs", + "weeklyCountdown", + "weeklyFill", + "weeklyPct", + "weeklyPctText", + "weeklyResetsAtMs", + ] + ); + + let panel = serde_json::to_value(panel_payload(ProviderId::Claude, None, &strings(), true)) + .expect("serialise"); + for key in [ + "provider", + "label", + "isDark", + "accent", + "sessionPct", + "weeklyPct", + "sessionText", + "weeklyText", + "sessionFill", + "weeklyFill", + ] { + assert!(panel.get(key).is_some(), "missing {key}"); + } + } + + #[test] + fn opencode_go_uses_weekly_and_monthly_windows() { + assert_eq!( + window_durations_secs(ProviderId::OpenCodeGo), + (604_800, 2_592_000) + ); + } +} diff --git a/src/core/state.rs b/src/core/state.rs new file mode 100644 index 0000000..2928336 --- /dev/null +++ b/src/core/state.rs @@ -0,0 +1,522 @@ +//! Portable app state rules: applying poll results, alert crossings and +//! cooldown, the tray tooltip, the update status label and the update-check +//! schedule. +//! +//! None of this touches a window, tray or menu. Callers apply the returned +//! values after dropping their state lock. + +use std::collections::HashMap; +use std::time::{Duration, Instant, SystemTime}; + +use crate::core::i18n::{self, LocaleStrings}; +use crate::usage::{ProviderId, UsageWindows}; + +/// Minimum gap between two cooldown-gated alerts (threshold and session +/// expired), so notifications stay calm. +pub const ALERT_COOLDOWN: Duration = Duration::from_secs(30 * 60); + +/// Usage thresholds that raise an alert the poll they are crossed. +pub const ALERT_THRESHOLDS: [u8; 2] = [80, 95]; + +/// Shortest countdown re-render delay. +const MIN_COUNTDOWN_DELAY: Duration = Duration::from_secs(1); + +/// One provider's last known usage plus its panel/tooltip texts. +#[derive(Clone, Debug, Default)] +pub struct ProviderUiState { + pub windows: UsageWindows, + /// `format_window` text for the session window, `"!"` after an auth + /// failure, `"…"` after a transient error. + pub primary_text: String, + /// Same as `primary_text` for the weekly window. + pub secondary_text: String, +} + +/// How one provider's poll ended, independent of the HTTP error type. +#[derive(Clone, Debug)] +pub enum PollOutcome { + Ok(UsageWindows), + /// The credentials were rejected (auth required or token expired). + AuthFailed, + /// Any other failure: network, bad response, missing credentials. + Failed, +} + +/// A notification to show. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Alert { + Threshold { provider: ProviderId, threshold: u8 }, + SessionExpired { provider: ProviderId }, + UpdateApplied { version: String }, +} + +impl Alert { + /// `(title, body)` for the notification. + pub fn text(&self, strings: &LocaleStrings) -> (String, String) { + match self { + Alert::Threshold { + provider, + threshold, + } => { + let title = format!("{} · {threshold}%", strings.provider_label(*provider)); + let body = if *threshold >= 95 { + strings.threshold_95_body.clone() + } else { + strings.threshold_80_body.clone() + }; + (title, body) + } + Alert::SessionExpired { provider } => match provider { + ProviderId::Claude => ( + strings.token_expired_title.clone(), + strings.token_expired_body.clone(), + ), + ProviderId::ChatGpt => ( + strings.chatgpt_token_expired_title.clone(), + strings.chatgpt_token_expired_body.clone(), + ), + ProviderId::OpenCodeGo => ( + strings.opencode_go_label.clone(), + strings.update_failed.clone(), + ), + }, + Alert::UpdateApplied { version } => ( + strings.update_applied_title.clone(), + format!("{}{version}", strings.update_applied_body), + ), + } + } +} + +/// What a batch of poll results changed beyond the state itself. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Applied { + /// Threshold alerts that passed the cooldown, in firing order. + pub alerts: Vec, + /// Providers whose credentials were rejected; the caller runs the CLI + /// refresh for them. + pub auth_failures: Vec, +} + +/// Usage snapshots and the alert cooldown. +#[derive(Clone, Debug, Default)] +pub struct UsageState { + pub snapshots: HashMap, + /// True when at least one provider succeeded on the last non-empty poll. + /// Shared by all providers, as before. + pub last_poll_ok: bool, + pub last_alert_at: Option, +} + +impl UsageState { + /// Apply one poll's results. A provider fires an alert only the poll its + /// session usage crosses 80 or 95, and each alert must pass the + /// cooldown. An empty batch changes nothing. + pub fn apply_results( + &mut self, + results: Vec<(ProviderId, PollOutcome)>, + strings: &LocaleStrings, + now: Instant, + ) -> Applied { + let mut applied = Applied::default(); + if results.is_empty() { + return applied; + } + let mut crossings: Vec<(ProviderId, u8)> = Vec::new(); + let mut any_ok = false; + for (id, outcome) in results { + let entry = self.snapshots.entry(id).or_default(); + match outcome { + PollOutcome::Ok(windows) => { + let old_pct = entry.windows.primary.utilization; + let new_pct = windows.primary.utilization; + for threshold in ALERT_THRESHOLDS { + if old_pct < threshold as f64 && new_pct >= threshold as f64 { + crossings.push((id, threshold)); + } + } + entry.windows = windows; + entry.primary_text = i18n::format_window(&windows.primary, strings); + entry.secondary_text = i18n::format_window(&windows.secondary, strings); + any_ok = true; + } + PollOutcome::AuthFailed => { + applied.auth_failures.push(id); + entry.primary_text = "!".into(); + entry.secondary_text = "!".into(); + } + PollOutcome::Failed => { + entry.primary_text = "…".into(); + entry.secondary_text = "…".into(); + } + } + } + self.last_poll_ok = any_ok; + for (provider, threshold) in crossings { + if self.admit_alert(now) { + applied.alerts.push(Alert::Threshold { + provider, + threshold, + }); + } + } + applied + } + + /// Claim the alert cooldown. Returns false while the previous gated + /// alert is younger than [`ALERT_COOLDOWN`]. + pub fn admit_alert(&mut self, now: Instant) -> bool { + if let Some(last) = self.last_alert_at { + if now.saturating_duration_since(last) < ALERT_COOLDOWN { + return false; + } + } + self.last_alert_at = Some(now); + true + } + + /// The session-expired alert for `provider`, if the cooldown allows one. + pub fn session_expired_alert(&mut self, provider: ProviderId, now: Instant) -> Option { + self.admit_alert(now) + .then_some(Alert::SessionExpired { provider }) + } + + /// Re-format every countdown text after time passed. Error markers are + /// replaced too, matching the countdown timer of the Win32 build. + pub fn refresh_texts(&mut self, strings: &LocaleStrings) { + for entry in self.snapshots.values_mut() { + entry.primary_text = i18n::format_window(&entry.windows.primary, strings); + entry.secondary_text = i18n::format_window(&entry.windows.secondary, strings); + } + } + + /// Delay until any displayed countdown changes, at least one second. + /// `None` when no window has a reset time ahead. + pub fn countdown_delay(&self, now: SystemTime) -> Option { + self.snapshots + .values() + .flat_map(|e| [e.windows.primary.resets_at, e.windows.secondary.resets_at]) + .filter_map(|reset| i18n::time_until_display_change_at(reset, now)) + .min() + .map(|d| d.max(MIN_COUNTDOWN_DELAY)) + } +} + +/// Four-line tray tooltip: label, session, weekly and the click hint. +pub fn tray_tooltip( + label: &str, + entry: Option<&ProviderUiState>, + strings: &LocaleStrings, +) -> String { + let session = entry + .map(|e| e.primary_text.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("..."); + let weekly = entry + .map(|e| e.secondary_text.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("..."); + format!( + "{label}\n{}: {session}\n{}: {weekly}\n{}", + strings.session_window, strings.weekly_window, strings.tray_left_click + ) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UpdateStatus { + #[default] + Idle, + Checking, + UpToDate, + Available, + Applying, + Failed, +} + +impl UpdateStatus { + /// Whether a check or apply is running, which disables the menu item. + pub fn is_busy(self) -> bool { + matches!(self, UpdateStatus::Checking | UpdateStatus::Applying) + } +} + +/// The version menu item: `" · v"`. +pub fn version_action_label(status: UpdateStatus, strings: &LocaleStrings) -> String { + let base = match status { + UpdateStatus::Idle => &strings.check_for_updates, + UpdateStatus::Checking => &strings.checking_for_updates, + UpdateStatus::UpToDate => &strings.up_to_date, + UpdateStatus::Available => &strings.update_available, + UpdateStatus::Applying => &strings.applying_update, + UpdateStatus::Failed => &strings.update_failed, + }; + // The running binary's version, so the user can see what they are on + // without an About dialog. The middle dot matches the countdown text. + format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION")) +} + +/// When the next automatic update check runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpdateCheckDue { + Now, + After(Duration), +} + +/// Schedule the automatic update check from the last check time (unix +/// seconds). `None` when auto-check is disabled. Due when never checked or +/// when `now - last >= interval`; otherwise after the remainder. +pub fn update_check_due( + last_check_unix: Option, + interval_secs: Option, + now_unix: u64, +) -> Option { + let interval = interval_secs?; + let Some(last) = last_check_unix else { + return Some(UpdateCheckDue::Now); + }; + let elapsed = now_unix.saturating_sub(last); + if elapsed >= interval { + Some(UpdateCheckDue::Now) + } else { + Some(UpdateCheckDue::After(Duration::from_secs( + interval - elapsed, + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::i18n::I18n; + use crate::usage::Window; + + fn windows(primary: f64) -> UsageWindows { + UsageWindows { + primary: Window { + utilization: primary, + resets_at: None, + }, + secondary: Window { + utilization: 10.0, + resets_at: None, + }, + } + } + + fn poll(state: &mut UsageState, pct: f64, now: Instant) -> Applied { + let strings = I18n::load().strings().clone(); + state.apply_results( + vec![(ProviderId::Claude, PollOutcome::Ok(windows(pct)))], + &strings, + now, + ) + } + + fn threshold(t: u8) -> Alert { + Alert::Threshold { + provider: ProviderId::Claude, + threshold: t, + } + } + + #[test] + fn crossing_eighty_fires_once() { + let now = Instant::now(); + let mut state = UsageState::default(); + assert!(poll(&mut state, 79.0, now).alerts.is_empty()); + assert_eq!(poll(&mut state, 80.0, now).alerts, [threshold(80)]); + // Parked above the threshold: no new crossing, even after the cooldown. + let later = now + ALERT_COOLDOWN; + assert!(poll(&mut state, 85.0, later).alerts.is_empty()); + } + + #[test] + fn double_crossing_is_cut_to_one_by_the_cooldown() { + let now = Instant::now(); + let mut state = UsageState::default(); + poll(&mut state, 79.0, now); + assert_eq!(poll(&mut state, 96.0, now).alerts, [threshold(80)]); + } + + #[test] + fn first_poll_above_threshold_alerts() { + let mut state = UsageState::default(); + assert_eq!( + poll(&mut state, 90.0, Instant::now()).alerts, + [threshold(80)] + ); + } + + #[test] + fn cooldown_expires_after_thirty_minutes() { + let now = Instant::now(); + let mut state = UsageState::default(); + assert!(state.admit_alert(now)); + assert!(!state.admit_alert(now + ALERT_COOLDOWN - Duration::from_secs(1))); + assert_eq!(state.session_expired_alert(ProviderId::ChatGpt, now), None); + assert_eq!( + state.session_expired_alert(ProviderId::ChatGpt, now + ALERT_COOLDOWN), + Some(Alert::SessionExpired { + provider: ProviderId::ChatGpt + }) + ); + } + + #[test] + fn errors_set_marker_texts_and_auth_failures() { + let strings = I18n::load().strings().clone(); + let mut state = UsageState::default(); + poll(&mut state, 40.0, Instant::now()); + let applied = state.apply_results( + vec![ + (ProviderId::Claude, PollOutcome::AuthFailed), + (ProviderId::ChatGpt, PollOutcome::Failed), + ], + &strings, + Instant::now(), + ); + assert_eq!(applied.auth_failures, [ProviderId::Claude]); + assert!(!state.last_poll_ok); + let claude = &state.snapshots[&ProviderId::Claude]; + assert_eq!(claude.primary_text, "!"); + assert_eq!(claude.secondary_text, "!"); + // The stored windows survive the error. + assert_eq!(claude.windows.primary.utilization, 40.0); + let codex = &state.snapshots[&ProviderId::ChatGpt]; + assert_eq!(codex.primary_text, "…"); + assert_eq!(codex.secondary_text, "…"); + } + + #[test] + fn last_poll_ok_is_global_and_empty_batches_change_nothing() { + let strings = I18n::load().strings().clone(); + let mut state = UsageState::default(); + let applied = state.apply_results( + vec![ + (ProviderId::Claude, PollOutcome::Failed), + (ProviderId::ChatGpt, PollOutcome::Ok(windows(5.0))), + ], + &strings, + Instant::now(), + ); + assert!(state.last_poll_ok); + assert!(applied.auth_failures.is_empty()); + assert_eq!(state.snapshots[&ProviderId::ChatGpt].primary_text, "5%"); + assert_eq!( + state.apply_results(Vec::new(), &strings, Instant::now()), + Applied::default() + ); + assert!(state.last_poll_ok); + } + + #[test] + fn alert_texts() { + let strings = I18n::load().strings().clone(); + assert_eq!( + threshold(95).text(&strings), + ( + "Claude Code · 95%".to_string(), + "Limit is close - consider easing up.".to_string() + ) + ); + assert_eq!( + Alert::SessionExpired { + provider: ProviderId::ChatGpt + } + .text(&strings) + .0, + "Codex session expired" + ); + assert_eq!( + Alert::UpdateApplied { + version: "0.6.0".into() + } + .text(&strings) + .1, + "Updated to v0.6.0" + ); + } + + #[test] + fn tooltip_uses_dots_for_empty_texts() { + let strings = I18n::load().strings().clone(); + assert_eq!( + tray_tooltip("Claude Code", None, &strings), + "Claude Code\n5h: ...\n7d: ...\nLeft-click: show/hide" + ); + let entry = ProviderUiState { + primary_text: "73% · 2h".into(), + secondary_text: String::new(), + ..ProviderUiState::default() + }; + assert_eq!( + tray_tooltip("Codex", Some(&entry), &strings), + "Codex\n5h: 73% · 2h\n7d: ...\nLeft-click: show/hide" + ); + } + + #[test] + fn version_label_carries_status_and_version() { + let strings = I18n::load().strings().clone(); + let v = env!("CARGO_PKG_VERSION"); + assert_eq!( + version_action_label(UpdateStatus::Idle, &strings), + format!("Check for updates · v{v}") + ); + assert_eq!( + version_action_label(UpdateStatus::Applying, &strings), + format!("Applying update... · v{v}") + ); + assert!(UpdateStatus::Checking.is_busy()); + assert!(!UpdateStatus::Available.is_busy()); + } + + #[test] + fn update_schedule_due_and_remaining() { + let hour = 3_600; + assert_eq!(update_check_due(None, None, 10_000), None); + assert_eq!(update_check_due(Some(9_000), None, 10_000), None); + assert_eq!( + update_check_due(None, Some(hour), 10_000), + Some(UpdateCheckDue::Now) + ); + assert_eq!( + update_check_due(Some(10_000 - hour), Some(hour), 10_000), + Some(UpdateCheckDue::Now) + ); + assert_eq!( + update_check_due(Some(10_000 - 600), Some(hour), 10_000), + Some(UpdateCheckDue::After(Duration::from_secs(3_000))) + ); + // A clock that moved backwards counts as zero elapsed. + assert_eq!( + update_check_due(Some(20_000), Some(hour), 10_000), + Some(UpdateCheckDue::After(Duration::from_secs(hour))) + ); + } + + #[test] + fn countdown_delay_takes_the_nearest_change_with_a_floor() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let mut state = UsageState::default(); + assert_eq!(state.countdown_delay(now), None); + let mut w = windows(10.0); + w.primary.resets_at = Some(now + Duration::from_secs(2 * 3_600 + 30)); + w.secondary.resets_at = Some(now + Duration::from_secs(3 * 86_400 + 90)); + state.snapshots.insert( + ProviderId::Claude, + ProviderUiState { + windows: w, + ..ProviderUiState::default() + }, + ); + assert_eq!(state.countdown_delay(now), Some(Duration::from_secs(31))); + w.primary.resets_at = Some(now + Duration::from_secs(20)); + state + .snapshots + .get_mut(&ProviderId::Claude) + .expect("entry") + .windows = w; + assert_eq!(state.countdown_delay(now), Some(Duration::from_secs(1))); + } +}