feat: add the portable state, payload and menu model

Poll results, threshold crossings and the alert cooldown now return
values instead of calling the tray, so they run under unit tests. The
bubble and panel payloads carry every display string and colour from
Rust, and the context menu is a plain tree with string ids that parse
back into commands. The update-check schedule and countdown delay are
plain functions of the stored times.
This commit is contained in:
tiennm99 committed 2026-10-21 12:23:53 +07:00
1 parent bc1a098875
commit 76ee0203cb
4 files changed
+1325

No files matched your search

+453
View File
@@ -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<u64>),
SizeSmaller,
SizeLarger,
SizeReset,
ToggleWidget,
Restart,
Exit,
}
impl MenuCommand {
/// The item id: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`,
/// `reset-position`, `version`, `update-interval:<secs|off>`,
/// `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<Self> {
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::<u32>().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::<u64>().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<MenuEntry>,
},
}
/// 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 {
MenuEntry::Submenu {
label: label.to_string(),
entries,
}
}
/// Build the context menu in display order.
pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
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<MenuEntry> {
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<String> = 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<MenuCommand>) {
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}");
}
}
}
+3
View File
@@ -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;
+347
View File
@@ -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<f64>,
pub weekly_pct: Option<f64>,
/// `"73%"`, or `"—"` with no value.
pub session_pct_text: String,
/// `None` hides the weekly percent.
pub weekly_pct_text: Option<String>,
/// 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<u64>,
pub weekly_resets_at_ms: Option<u64>,
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<SystemTime>) -> Option<u64> {
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)
);
}
}
+522
View File
@@ -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<Alert>,
/// Providers whose credentials were rejected; the caller runs the CLI
/// refresh for them.
pub auth_failures: Vec<ProviderId>,
}
/// Usage snapshots and the alert cooldown.
#[derive(Clone, Debug, Default)]
pub struct UsageState {
pub snapshots: HashMap<ProviderId, ProviderUiState>,
/// 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<Instant>,
}
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<Alert> {
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<Duration> {
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: `"<status> · v<version>"`.
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<u64>,
interval_secs: Option<u64>,
now_unix: u64,
) -> Option<UpdateCheckDue> {
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)));
}
}