From 80ed9598f29b7d218880674429a62f4db4e7a2bf Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:41:44 +0300 Subject: [PATCH 1/4] feat: add system locale display modes --- CHANGELOG.md | 6 + Cargo.lock | 2 + Cargo.toml | 6 + README.md | 2 + src/app/mod.rs | 62 +++++ src/locale.rs | 709 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 + src/read/tui.rs | 115 ++++++-- src/ui/mod.rs | 578 +++++++++++++++++++++++++------------- src/usage/mod.rs | 134 ++++----- 10 files changed, 1336 insertions(+), 281 deletions(-) create mode 100644 src/locale.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f5248..a8048ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented in this file. +## Unreleased + +- Added a persisted `n` shortcut to cycle display formatting through Classic, System Compact, and System Full for numbers, dates, times, and calendar labels. +- Clarified token pair charts with the `TOKENS (TOTAL / NON-CACHED)` heading. +- Added compact `K/M/B/T` notation for large dashboard token values in System Compact mode, including values inside vertical chart bars. System Full keeps those values expanded, while Classic output remains unchanged. + ## 0.3.6 - 2026-06-05 - Added `--live-limits auto|on|off` so app-only installs can run without a live-limits spawn error. diff --git a/Cargo.lock b/Cargo.lock index 0c895cd..22e012c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,12 +265,14 @@ dependencies = [ "chrono", "clap", "crossterm", + "libc", "ratatui", "rusqlite", "serde", "serde_json", "tokio", "unicode-width", + "windows-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 43a555b..caad8b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,9 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] } unicode-width = "0.2.2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.180" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Globalization"] } diff --git a/README.md b/README.md index 2cc93d3..2e19d7b 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ comon --scan-time-budget-ms 1500 --max-jsonl-line-kib 512 - `Tab` Toggle data (Tokens/Time/Runs) - `w` Toggle timeframe (Week/Month) - `f` Toggle layout (Horz/Vert) +- `n` Cycle display formatting (Classic/System Compact/System Full) - `s` / `F2` Switch between Usage and Session history - `r` / `F5` Refresh current screen - `?` Help overlay @@ -341,6 +342,7 @@ CI also runs this check on each push and pull request via `.github/workflows/asc - Usage stats are derived from Codex session JSONL logs. If you have no session data yet, values will be empty. - Limits/credits require Codex App Server to start successfully (auth, environment, and a usable working directory). CoMon auto-detects `codex` on `PATH` and common Windows Codex App bundle locations; use `--codex-bin` or `--app-server-bin` when needed. - comon stores local app state in `~/.comon/state.json` by default (or `$COMON_HOME`, or `--comon-home`). +- Display formatting starts in Classic mode; press `n` to cycle through Classic, System Compact, and System Full. Both System modes use the operating system locale for numbers, dates, times, and calendar labels; Compact abbreviates dashboard token values, while Full keeps them expanded except for the pre-existing compact summary values. The choice is saved in `state.json` without changing stored data. - comon stores scan cache in `~/.comon/comon.db` to avoid rereading unchanged session files. - Large session logs are parsed incrementally with persisted parser offsets in `comon.db`; unchanged files are reused from cache. - If historical days look incomplete after adding old session files, run once with `--full-scan --scan-time-budget-ms 0` to force a full reparse and refresh cached summaries. diff --git a/src/app/mod.rs b/src/app/mod.rs index 3ec82b8..01a4b40 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,5 @@ use crate::codex_rpc::{AccountRateLimits, CodexRpc}; +use crate::locale::{DisplayFormatter, DisplayStyle, SystemLocale}; use crate::read; use crate::usage::{ChartRange, LocalUsageSnapshot, UsageMetric}; use anyhow::{anyhow, Context, Result}; @@ -28,6 +29,7 @@ pub struct Config { pub refresh_limits_secs: u64, pub usage_scan_limits: crate::usage::ScanLimits, pub rebuild_cache_on_start: bool, + pub(crate) system_locale: SystemLocale, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -109,6 +111,8 @@ pub(crate) struct AppState { pub(crate) workspace_path: Option, pub(crate) no_sessions_confirm_open: bool, pub(crate) no_sessions_confirm_dismissed: bool, + pub(crate) display_style: DisplayStyle, + pub(crate) system_locale: SystemLocale, pub(crate) usage: Option, pub(crate) usage_updated_at: Option, @@ -144,6 +148,7 @@ struct PersistedUiState { activity_project_limit: usize, workspace_path: Option, no_sessions_confirm_dismissed: bool, + display_style: DisplayStyle, } impl PersistedUiState { @@ -155,6 +160,7 @@ impl PersistedUiState { activity_project_limit: DEFAULT_ACTIVITY_PROJECT_LIMIT, workspace_path, no_sessions_confirm_dismissed: false, + display_style: DisplayStyle::Classic, } } @@ -166,6 +172,7 @@ impl PersistedUiState { activity_project_limit: state.activity_project_limit, workspace_path: state.workspace_path.clone(), no_sessions_confirm_dismissed: state.no_sessions_confirm_dismissed, + display_style: state.display_style, } } } @@ -195,6 +202,7 @@ struct StoredGlobalState { range: Option, orientation: Option, activity_project_limit: Option, + display_style: Option, last_workspace_path: Option, updated_at: i64, } @@ -443,6 +451,8 @@ async fn run_inner( workspace_path: restored_ui_state.workspace_path.clone(), no_sessions_confirm_open: false, no_sessions_confirm_dismissed: restored_ui_state.no_sessions_confirm_dismissed, + display_style: restored_ui_state.display_style, + system_locale: config.system_locale.clone(), usage: None, usage_updated_at: None, usage_error: None, @@ -576,6 +586,10 @@ fn handle_input_event( state.active_screen = next_active_screen(state.active_screen); return Ok(InputOutcome::Continue(true)); } + (KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) => { + state.display_style = state.display_style.toggled(); + return Ok(InputOutcome::Continue(true)); + } (KeyCode::Char('r'), _) | (KeyCode::F(5), _) if state.active_screen == ActiveScreen::Read => { @@ -882,6 +896,11 @@ fn load_persisted_ui_state( state.activity_project_limit = limit.clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT); } + if let Some(style_text) = store.global.display_style.as_deref() { + if let Some(style) = DisplayStyle::from_store(style_text) { + state.display_style = style; + } + } if let Some(workspace_path) = state.workspace_path.as_ref() { let workspace_key = workspace_path.to_string_lossy(); @@ -909,6 +928,7 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul .activity_project_limit .clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT), ); + store.global.display_style = Some(state.display_style.store_value().to_string()); store.global.last_workspace_path = workspace_path_text.clone(); store.global.updated_at = now; @@ -1008,6 +1028,10 @@ fn unix_time_seconds() -> i64 { } impl AppState { + pub(crate) fn formatter(&self) -> DisplayFormatter<'_> { + DisplayFormatter::new(self.display_style, &self.system_locale) + } + pub(crate) fn usage_updated_label(&self) -> Option { let updated_at = self.usage_updated_at?; Some(crate::ui::format_updated_label(updated_at)) @@ -1094,4 +1118,42 @@ mod tests { let _ = std::fs::remove_dir_all(comon_home); } + + #[test] + fn legacy_state_without_display_style_defaults_to_classic() { + let comon_home = make_temp_dir("legacy-display-style"); + let store = StateStore::default(); + write_state_store(&comon_home, &store).expect("write state store"); + + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!(loaded.display_style, DisplayStyle::Classic); + + let _ = std::fs::remove_dir_all(comon_home); + } + + #[test] + fn display_style_round_trips_through_state_store() { + let comon_home = make_temp_dir("system-display-style"); + let mut state = PersistedUiState::default_for_workspace(None); + state.display_style = DisplayStyle::SystemFull; + + save_persisted_ui_state(&comon_home, &state).expect("save persisted ui state"); + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!(loaded.display_style, DisplayStyle::SystemFull); + + let _ = std::fs::remove_dir_all(comon_home); + } + + #[test] + fn legacy_system_style_restores_as_system_compact() { + let comon_home = make_temp_dir("legacy-system-display-style"); + let mut store = StateStore::default(); + store.global.display_style = Some("system".to_string()); + write_state_store(&comon_home, &store).expect("write state store"); + + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!(loaded.display_style, DisplayStyle::SystemCompact); + + let _ = std::fs::remove_dir_all(comon_home); + } } diff --git a/src/locale.rs b/src/locale.rs new file mode 100644 index 0000000..6e3d1ec --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,709 @@ +use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike, Weekday}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DisplayStyle { + Classic, + SystemCompact, + SystemFull, +} + +impl DisplayStyle { + pub(crate) fn toggled(self) -> Self { + match self { + Self::Classic => Self::SystemCompact, + Self::SystemCompact => Self::SystemFull, + Self::SystemFull => Self::Classic, + } + } + + pub(crate) fn store_value(self) -> &'static str { + match self { + Self::Classic => "classic", + Self::SystemCompact => "system-compact", + Self::SystemFull => "system-full", + } + } + + pub(crate) fn from_store(value: &str) -> Option { + match value { + "classic" => Some(Self::Classic), + "system" | "system-compact" => Some(Self::SystemCompact), + "system-full" => Some(Self::SystemFull), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DateOrder { + DayMonthYear, + MonthDayYear, + YearMonthDay, +} + +#[derive(Debug, Clone)] +pub(crate) struct SystemLocale { + id: String, + decimal_separator: String, + thousands_separator: String, + date_order: DateOrder, + date_separator: String, + time_24h: bool, + time_separator: String, + abbreviated_weekdays: [String; 7], + abbreviated_months: [String; 12], + am: String, + pm: String, +} + +impl SystemLocale { + pub(crate) fn detect() -> Self { + platform::detect().unwrap_or_else(Self::c_locale) + } + + fn c_locale() -> Self { + Self { + id: "C".to_string(), + decimal_separator: ".".to_string(), + thousands_separator: String::new(), + date_order: DateOrder::MonthDayYear, + date_separator: "/".to_string(), + time_24h: true, + time_separator: ":".to_string(), + abbreviated_weekdays: english_weekdays(), + abbreviated_months: english_months(), + am: "AM".to_string(), + pm: "PM".to_string(), + } + } + + pub(crate) fn id(&self) -> &str { + &self.id + } +} + +impl Default for SystemLocale { + fn default() -> Self { + Self::c_locale() + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DisplayFormatter<'a> { + style: DisplayStyle, + system: &'a SystemLocale, +} + +impl<'a> DisplayFormatter<'a> { + pub(crate) fn new(style: DisplayStyle, system: &'a SystemLocale) -> Self { + Self { style, system } + } + + pub(crate) fn style(self) -> DisplayStyle { + self.style + } + + pub(crate) fn style_label(self) -> String { + match self.style { + DisplayStyle::Classic => "Classic".to_string(), + DisplayStyle::SystemCompact => { + format!("System Compact ({})", self.system.id()) + } + DisplayStyle::SystemFull => format!("System Full ({})", self.system.id()), + } + } + + pub(crate) fn format_count(self, value: i64) -> String { + self.format_u64(value.max(0) as u64) + } + + pub(crate) fn format_usize(self, value: usize) -> String { + self.format_u64(value as u64) + } + + pub(crate) fn format_u64(self, value: u64) -> String { + let separator = match self.style { + DisplayStyle::Classic => ",", + DisplayStyle::SystemCompact | DisplayStyle::SystemFull => { + self.system.thousands_separator.as_str() + } + }; + group_decimal_digits(value, separator) + } + + pub(crate) fn localize_decimal(self, value: &str) -> String { + if self.style == DisplayStyle::Classic || self.system.decimal_separator == "." { + return value.to_string(); + } + value.replacen('.', &self.system.decimal_separator, 1) + } + + pub(crate) fn format_one_decimal(self, value: f64) -> String { + self.localize_decimal(&format!("{:.1}", value)) + } + + pub(crate) fn format_chart_day(self, date: NaiveDate) -> String { + if self.style == DisplayStyle::Classic { + return format!( + "{} {:02}/{:02}", + classic_weekday(date.weekday()), + date.month(), + date.day() + ); + } + let weekday = &self.system.abbreviated_weekdays[weekday_index(date.weekday())]; + let compact = self.system.format_numeric_date(date, false); + format!("{weekday} {compact}") + } + + pub(crate) fn format_short_date(self, date: NaiveDate) -> String { + if self.style == DisplayStyle::Classic { + return format!("{} {:02}", english_month(date.month()), date.day()); + } + let month = &self.system.abbreviated_months[date.month0() as usize]; + match self.system.date_order { + DateOrder::MonthDayYear => format!("{month} {:02}", date.day()), + DateOrder::DayMonthYear | DateOrder::YearMonthDay => { + format!("{:02} {month}", date.day()) + } + } + } + + pub(crate) fn abbreviated_month(self, month: u32) -> String { + if self.style == DisplayStyle::Classic { + return english_month(month).to_string(); + } + self.system + .abbreviated_months + .get(month.saturating_sub(1) as usize) + .cloned() + .unwrap_or_default() + } + + pub(crate) fn abbreviated_weekday(self, day: Weekday) -> String { + if self.style == DisplayStyle::Classic { + return classic_weekday(day).to_string(); + } + self.system.abbreviated_weekdays[weekday_index(day)].clone() + } + + pub(crate) fn format_full_date(self, date: NaiveDate) -> String { + if self.style == DisplayStyle::Classic { + return date.format("%Y-%m-%d").to_string(); + } + self.system.format_numeric_date(date, true) + } + + pub(crate) fn format_time(self, date_time: NaiveDateTime) -> String { + if self.style == DisplayStyle::Classic { + return format!("{:02}:{:02}", date_time.hour(), date_time.minute()); + } + let separator = &self.system.time_separator; + if self.system.time_24h { + return format!( + "{:02}{separator}{:02}", + date_time.hour(), + date_time.minute() + ); + } + let hour = match date_time.hour() % 12 { + 0 => 12, + value => value, + }; + let marker = if date_time.hour() < 12 { + &self.system.am + } else { + &self.system.pm + }; + format!("{hour}{separator}{:02} {marker}", date_time.minute()) + } + + pub(crate) fn format_session_datetime(self, date_time: NaiveDateTime) -> String { + format!( + "{} {}", + self.format_full_date(date_time.date()), + self.format_time(date_time) + ) + } + + pub(crate) fn format_reset_datetime(self, date_time: NaiveDateTime) -> String { + format!( + "{}, {}", + self.format_reset_date(date_time.date()), + self.format_time(date_time) + ) + } + + pub(crate) fn format_reset_date(self, date: NaiveDate) -> String { + if self.style == DisplayStyle::Classic { + return format!("{} {}", date.day(), english_month(date.month())); + } + self.format_short_date(date) + } +} + +impl SystemLocale { + fn format_numeric_date(&self, date: NaiveDate, include_year: bool) -> String { + let separator = &self.date_separator; + let day = format!("{:02}", date.day()); + let month = format!("{:02}", date.month()); + let year = format!("{:04}", date.year()); + let pieces: Vec<&str> = match (self.date_order, include_year) { + (DateOrder::DayMonthYear, true) => vec![&day, &month, &year], + (DateOrder::DayMonthYear, false) => vec![&day, &month], + (DateOrder::MonthDayYear, true) => vec![&month, &day, &year], + (DateOrder::MonthDayYear, false) => vec![&month, &day], + (DateOrder::YearMonthDay, true) => vec![&year, &month, &day], + (DateOrder::YearMonthDay, false) => vec![&month, &day], + }; + pieces.join(separator) + } +} + +fn group_decimal_digits(value: u64, separator: &str) -> String { + let digits = value.to_string(); + if separator.is_empty() || digits.len() <= 3 { + return digits; + } + let first = match digits.len() % 3 { + 0 => 3, + value => value, + }; + let mut out = String::with_capacity(digits.len() + separator.len() * (digits.len() / 3)); + out.push_str(&digits[..first]); + let mut offset = first; + while offset < digits.len() { + out.push_str(separator); + out.push_str(&digits[offset..offset + 3]); + offset += 3; + } + out +} + +fn weekday_index(day: Weekday) -> usize { + day.num_days_from_monday() as usize +} + +fn classic_weekday(day: Weekday) -> &'static str { + match day { + Weekday::Mon => "Mon", + Weekday::Tue => "Tue", + Weekday::Wed => "Wed", + Weekday::Thu => "Thu", + Weekday::Fri => "Fri", + Weekday::Sat => "Sat", + Weekday::Sun => "Sun", + } +} + +fn english_month(month: u32) -> &'static str { + match month { + 1 => "Jan", + 2 => "Feb", + 3 => "Mar", + 4 => "Apr", + 5 => "May", + 6 => "Jun", + 7 => "Jul", + 8 => "Aug", + 9 => "Sep", + 10 => "Oct", + 11 => "Nov", + 12 => "Dec", + _ => "", + } +} + +fn english_weekdays() -> [String; 7] { + ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(str::to_string) +} + +fn english_months() -> [String; 12] { + [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + .map(str::to_string) +} + +fn parse_posix_date_pattern(pattern: &str) -> (DateOrder, String) { + let mut fields = Vec::new(); + let mut chars = pattern.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '%' { + continue; + } + let Some(code) = chars.next() else { break }; + let field = match code { + 'd' | 'e' => Some('d'), + 'm' | 'b' | 'B' | 'h' => Some('m'), + 'y' | 'Y' => Some('y'), + _ => None, + }; + if let Some(field) = field { + if !fields.contains(&field) { + fields.push(field); + } + } + } + let order = date_order_from_fields(&fields); + let separator = pattern + .chars() + .find(|ch| !ch.is_ascii_alphanumeric() && !matches!(ch, '%' | ' ' | '-' | '_')) + .or_else(|| pattern.chars().find(|ch| matches!(ch, '/' | '.' | '-'))) + .unwrap_or('-') + .to_string(); + (order, separator) +} + +#[cfg(any(windows, test))] +fn parse_windows_date_pattern(pattern: &str) -> (DateOrder, String) { + let mut fields = Vec::new(); + let mut quoted = false; + for ch in pattern.chars() { + if ch == '\'' { + quoted = !quoted; + continue; + } + if quoted { + continue; + } + let field = match ch { + 'd' => Some('d'), + 'M' => Some('m'), + 'y' => Some('y'), + _ => None, + }; + if let Some(field) = field { + if !fields.contains(&field) { + fields.push(field); + } + } + } + let order = date_order_from_fields(&fields); + let separator = pattern + .chars() + .find(|ch| !ch.is_ascii_alphanumeric() && !matches!(ch, '\'' | ' ')) + .unwrap_or('-') + .to_string(); + (order, separator) +} + +fn parse_posix_time_separator(pattern: &str) -> String { + for hour_code in ["%H", "%I", "%k", "%l"] { + if let Some(offset) = pattern.find(hour_code) { + let tail = &pattern[offset + hour_code.len()..]; + if let Some(separator) = tail + .chars() + .find(|ch| !ch.is_ascii_alphanumeric() && *ch != '%') + { + return separator.to_string(); + } + } + } + ":".to_string() +} + +#[cfg(any(windows, test))] +fn parse_windows_time_separator(pattern: &str) -> String { + let mut saw_hour = false; + for ch in pattern.chars() { + if !saw_hour { + saw_hour = matches!(ch, 'h' | 'H'); + continue; + } + if matches!(ch, 'h' | 'H') { + continue; + } + if ch == 'm' { + break; + } + if !ch.is_ascii_alphanumeric() && ch != '\'' { + return ch.to_string(); + } + } + ":".to_string() +} + +fn date_order_from_fields(fields: &[char]) -> DateOrder { + match fields.first().copied() { + Some('d') => DateOrder::DayMonthYear, + Some('y') => DateOrder::YearMonthDay, + _ => DateOrder::MonthDayYear, + } +} + +#[cfg(unix)] +mod platform { + use super::*; + use std::ffi::{CStr, CString}; + + pub(super) fn detect() -> Option { + let requested = CString::new("").ok()?; + let locale = + unsafe { libc::newlocale(libc::LC_ALL_MASK, requested.as_ptr(), std::ptr::null_mut()) }; + let locale = if locale.is_null() { + let fallback = CString::new("C").ok()?; + unsafe { libc::newlocale(libc::LC_ALL_MASK, fallback.as_ptr(), std::ptr::null_mut()) } + } else { + locale + }; + if locale.is_null() { + return None; + } + + let previous = unsafe { libc::uselocale(locale) }; + let profile = capture_current_thread_locale(); + unsafe { + libc::uselocale(previous); + libc::freelocale(locale); + } + profile + } + + fn capture_current_thread_locale() -> Option { + let decimal_separator = langinfo(libc::RADIXCHAR) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| ".".to_string()); + let thousands_separator = langinfo(libc::THOUSEP).unwrap_or_default(); + let date_pattern = langinfo(libc::D_FMT).unwrap_or_else(|| "%m/%d/%y".to_string()); + let time_pattern = langinfo(libc::T_FMT).unwrap_or_else(|| "%H:%M:%S".to_string()); + let (date_order, date_separator) = parse_posix_date_pattern(&date_pattern); + let abbreviated_weekdays = [ + langinfo(libc::ABDAY_2), + langinfo(libc::ABDAY_3), + langinfo(libc::ABDAY_4), + langinfo(libc::ABDAY_5), + langinfo(libc::ABDAY_6), + langinfo(libc::ABDAY_7), + langinfo(libc::ABDAY_1), + ] + .map(|value| value.unwrap_or_default()); + let abbreviated_months = [ + langinfo(libc::ABMON_1), + langinfo(libc::ABMON_2), + langinfo(libc::ABMON_3), + langinfo(libc::ABMON_4), + langinfo(libc::ABMON_5), + langinfo(libc::ABMON_6), + langinfo(libc::ABMON_7), + langinfo(libc::ABMON_8), + langinfo(libc::ABMON_9), + langinfo(libc::ABMON_10), + langinfo(libc::ABMON_11), + langinfo(libc::ABMON_12), + ] + .map(|value| value.unwrap_or_default()); + let id = locale_id_from_env(); + Some(SystemLocale { + id, + decimal_separator, + thousands_separator, + date_order, + date_separator, + time_24h: time_pattern.contains("%H") || time_pattern.contains("%k"), + time_separator: parse_posix_time_separator(&time_pattern), + abbreviated_weekdays: fill_empty(abbreviated_weekdays, english_weekdays()), + abbreviated_months: fill_empty(abbreviated_months, english_months()), + am: langinfo(libc::AM_STR) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "AM".to_string()), + pm: langinfo(libc::PM_STR) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "PM".to_string()), + }) + } + + fn langinfo(item: libc::nl_item) -> Option { + let ptr = unsafe { libc::nl_langinfo(item) }; + if ptr.is_null() { + return None; + } + Some( + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } + + fn locale_id_from_env() -> String { + for key in ["LC_ALL", "LC_TIME", "LC_NUMERIC", "LANG"] { + if let Ok(value) = std::env::var(key) { + let value = value.trim(); + if !value.is_empty() { + return value.to_string(); + } + } + } + "C".to_string() + } + + fn fill_empty(mut values: [String; N], fallback: [String; N]) -> [String; N] { + for (value, fallback) in values.iter_mut().zip(fallback) { + if value.is_empty() { + *value = fallback; + } + } + values + } +} + +#[cfg(windows)] +mod platform { + use super::*; + use windows_sys::Win32::Globalization::*; + + pub(super) fn detect() -> Option { + let id = user_locale_name().unwrap_or_else(|| "system".to_string()); + let date_pattern = locale_string(LOCALE_SSHORTDATE)?; + let time_pattern = locale_string(LOCALE_STIMEFORMAT)?; + let (date_order, date_separator) = parse_windows_date_pattern(&date_pattern); + let abbreviated_weekdays = std::array::from_fn(|index| { + locale_string(LOCALE_SABBREVDAYNAME1 + index as u32).unwrap_or_default() + }); + let abbreviated_months = std::array::from_fn(|index| { + locale_string(LOCALE_SABBREVMONTHNAME1 + index as u32).unwrap_or_default() + }); + Some(SystemLocale { + id, + decimal_separator: locale_string(LOCALE_SDECIMAL).unwrap_or_else(|| ".".to_string()), + thousands_separator: locale_string(LOCALE_STHOUSAND).unwrap_or_default(), + date_order, + date_separator, + time_24h: time_pattern.contains('H'), + time_separator: parse_windows_time_separator(&time_pattern), + abbreviated_weekdays: fill_empty(abbreviated_weekdays, english_weekdays()), + abbreviated_months: fill_empty(abbreviated_months, english_months()), + am: locale_string(LOCALE_S1159).unwrap_or_else(|| "AM".to_string()), + pm: locale_string(LOCALE_S2359).unwrap_or_else(|| "PM".to_string()), + }) + } + + fn locale_string(kind: u32) -> Option { + let required = unsafe { GetLocaleInfoEx(std::ptr::null(), kind, std::ptr::null_mut(), 0) }; + if required <= 1 { + return None; + } + let mut buffer = vec![0u16; required as usize]; + let written = + unsafe { GetLocaleInfoEx(std::ptr::null(), kind, buffer.as_mut_ptr(), required) }; + if written <= 1 { + return None; + } + buffer.truncate((written - 1) as usize); + String::from_utf16(&buffer).ok() + } + + fn user_locale_name() -> Option { + let mut buffer = [0u16; 85]; + let written = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; + if written <= 1 { + return None; + } + String::from_utf16(&buffer[..(written - 1) as usize]).ok() + } + + fn fill_empty(mut values: [String; N], fallback: [String; N]) -> [String; N] { + for (value, fallback) in values.iter_mut().zip(fallback) { + if value.is_empty() { + *value = fallback; + } + } + values + } +} + +#[cfg(not(any(unix, windows)))] +mod platform { + use super::*; + + pub(super) fn detect() -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn european_profile() -> SystemLocale { + SystemLocale { + id: "et-EE".to_string(), + decimal_separator: ",".to_string(), + thousands_separator: "\u{a0}".to_string(), + date_order: DateOrder::DayMonthYear, + date_separator: ".".to_string(), + time_24h: true, + time_separator: ":".to_string(), + abbreviated_weekdays: english_weekdays(), + abbreviated_months: english_months(), + am: "AM".to_string(), + pm: "PM".to_string(), + } + } + + #[test] + fn classic_preserves_existing_number_format() { + let system = european_profile(); + let formatter = DisplayFormatter::new(DisplayStyle::Classic, &system); + assert_eq!(formatter.format_count(1_234_567), "1,234,567"); + assert_eq!(formatter.localize_decimal("1.23M"), "1.23M"); + } + + #[test] + fn display_style_cycles_and_accepts_legacy_system_value() { + assert_eq!(DisplayStyle::Classic.toggled(), DisplayStyle::SystemCompact); + assert_eq!( + DisplayStyle::SystemCompact.toggled(), + DisplayStyle::SystemFull + ); + assert_eq!(DisplayStyle::SystemFull.toggled(), DisplayStyle::Classic); + assert_eq!( + DisplayStyle::from_store("system"), + Some(DisplayStyle::SystemCompact) + ); + } + + #[test] + fn system_uses_detected_number_separators() { + let system = european_profile(); + let formatter = DisplayFormatter::new(DisplayStyle::SystemCompact, &system); + assert_eq!(formatter.format_count(1_234_567), "1\u{a0}234\u{a0}567"); + assert_eq!(formatter.localize_decimal("1.23M"), "1,23M"); + } + + #[test] + fn system_formats_dates_and_time_from_profile() { + let system = european_profile(); + let formatter = DisplayFormatter::new(DisplayStyle::SystemCompact, &system); + let date = NaiveDate::from_ymd_opt(2026, 3, 4).unwrap(); + let date_time = date.and_hms_opt(14, 5, 0).unwrap(); + assert_eq!(formatter.format_chart_day(date), "Wed 04.03"); + assert_eq!(formatter.format_short_date(date), "04 Mar"); + assert_eq!( + formatter.format_session_datetime(date_time), + "04.03.2026 14:05" + ); + } + + #[test] + fn parses_common_posix_and_windows_date_patterns() { + assert_eq!( + parse_posix_date_pattern("%m/%d/%y"), + (DateOrder::MonthDayYear, "/".to_string()) + ); + assert_eq!( + parse_posix_date_pattern("%d.%m.%Y"), + (DateOrder::DayMonthYear, ".".to_string()) + ); + assert_eq!( + parse_windows_date_pattern("yyyy-MM-dd"), + (DateOrder::YearMonthDay, "-".to_string()) + ); + assert_eq!( + parse_windows_date_pattern("dd.MM.yyyy"), + (DateOrder::DayMonthYear, ".".to_string()) + ); + assert_eq!(parse_posix_time_separator("%H.%M.%S"), "."); + assert_eq!(parse_windows_time_separator("HH.mm.ss"), "."); + } +} diff --git a/src/main.rs b/src/main.rs index 08453f9..e993675 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod codex_rpc; +mod locale; mod read; mod storage; mod ui; @@ -357,6 +358,7 @@ async fn main() -> Result<()> { full_scan, scan_cache_max_entries, }; + let system_locale = locale::SystemLocale::detect(); let config = app::Config { codex_bin: args.codex_bin.clone(), @@ -373,6 +375,7 @@ async fn main() -> Result<()> { refresh_limits_secs, usage_scan_limits, rebuild_cache_on_start: args.rebuild_cache_on_start, + system_locale, }; app::run(config).await diff --git a/src/read/tui.rs b/src/read/tui.rs index 11264eb..b5dbe72 100644 --- a/src/read/tui.rs +++ b/src/read/tui.rs @@ -1,8 +1,10 @@ +use crate::locale::{DisplayFormatter, DisplayStyle}; use crate::read::scan::{ load_session_detail, truncate_single_line, Catalog, ProjectRecord, SessionDetail, SessionSummary, }; use anyhow::Result; +use chrono::{DateTime, Local}; use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -321,7 +323,12 @@ fn click_session_row(state: &mut BrowserState, row: u16) -> bool { true } -pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserState) { +pub(crate) fn render( + frame: &mut Frame<'_>, + area: Rect, + state: &mut BrowserState, + formatter: DisplayFormatter<'_>, +) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -331,15 +338,20 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserState ]) .split(area); - render_header(frame, chunks[0], state); + render_header(frame, chunks[0], state, formatter); state.layout = match state.view { - ViewMode::Projects => render_projects_view(frame, chunks[1], state), - ViewMode::Sessions => render_sessions_view(frame, chunks[1], state), + ViewMode::Projects => render_projects_view(frame, chunks[1], state, formatter), + ViewMode::Sessions => render_sessions_view(frame, chunks[1], state, formatter), }; - render_footer(frame, chunks[2], state); + render_footer(frame, chunks[2], state, formatter); } -fn render_header(frame: &mut Frame<'_>, area: Rect, state: &BrowserState) { +fn render_header( + frame: &mut Frame<'_>, + area: Rect, + state: &BrowserState, + formatter: DisplayFormatter<'_>, +) { let row = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(32), Constraint::Length(30)]) @@ -359,9 +371,9 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, state: &BrowserState) { let summary = format!( "{} projects {} files {} skipped", - state.catalog.projects.len(), - state.catalog.files_scanned, - state.catalog.files_skipped + formatter.format_usize(state.catalog.projects.len()), + formatter.format_usize(state.catalog.files_scanned), + formatter.format_usize(state.catalog.files_skipped) ); frame.render_widget( Paragraph::new(Line::from(Span::styled( @@ -372,7 +384,12 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, state: &BrowserState) { ); } -fn render_projects_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserState) -> UiLayout { +fn render_projects_view( + frame: &mut Frame<'_>, + area: Rect, + state: &mut BrowserState, + formatter: DisplayFormatter<'_>, +) -> UiLayout { let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(48), Constraint::Percentage(52)]) @@ -401,7 +418,7 @@ fn render_projects_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserSt .highlight_symbol(">> "); frame.render_stateful_widget(list, columns[0], &mut state.project_state); - let detail_text = render_project_detail(state.selected_project()); + let detail_text = render_project_detail(state.selected_project(), formatter); frame.render_widget( Paragraph::new(detail_text) .block( @@ -419,7 +436,12 @@ fn render_projects_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserSt } } -fn render_sessions_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserState) -> UiLayout { +fn render_sessions_view( + frame: &mut Frame<'_>, + area: Rect, + state: &mut BrowserState, + formatter: DisplayFormatter<'_>, +) -> UiLayout { let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(44), Constraint::Percentage(56)]) @@ -434,7 +456,7 @@ fn render_sessions_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserSt .map(|session| { let label = format!( "{} {}", - session.started_at_label, + session_started_label(session, formatter), truncate_single_line(&session.title, 64) ); ListItem::new(Line::from(label)) @@ -458,8 +480,11 @@ fn render_sessions_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserSt .highlight_symbol(">> "); frame.render_stateful_widget(list, columns[0], &mut state.session_state); - let detail_text = - render_session_detail(state.selected_session(), state.selected_session_detail()); + let detail_text = render_session_detail( + state.selected_session(), + state.selected_session_detail(), + formatter, + ); frame.render_widget( Paragraph::new(detail_text) .block( @@ -477,7 +502,10 @@ fn render_sessions_view(frame: &mut Frame<'_>, area: Rect, state: &mut BrowserSt } } -fn render_project_detail(project: Option<&ProjectRecord>) -> Text<'static> { +fn render_project_detail( + project: Option<&ProjectRecord>, + formatter: DisplayFormatter<'_>, +) -> Text<'static> { let Some(project) = project else { return Text::from("No projects found."); }; @@ -485,7 +513,7 @@ fn render_project_detail(project: Option<&ProjectRecord>) -> Text<'static> { let latest = project .sessions .first() - .map(|session| session.started_at_label.clone()) + .map(|session| session_started_label(session, formatter)) .unwrap_or_else(|| "--".to_string()); let mut lines = vec![ @@ -500,7 +528,7 @@ fn render_project_detail(project: Option<&ProjectRecord>) -> Text<'static> { Line::from(vec![ Span::styled("SESSIONS", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::raw(project.sessions.len().to_string()), + Span::raw(formatter.format_usize(project.sessions.len())), ]), Line::from(vec![ Span::styled("LATEST", Style::default().fg(Color::Gray)), @@ -519,7 +547,7 @@ fn render_project_detail(project: Option<&ProjectRecord>) -> Text<'static> { for session in project.sessions.iter().take(10) { lines.push(Line::from(format!( "{} {}", - session.started_at_label, + session_started_label(session, formatter), truncate_single_line(&session.title, 72) ))); } @@ -530,6 +558,7 @@ fn render_project_detail(project: Option<&ProjectRecord>) -> Text<'static> { fn render_session_detail( session: Option<&SessionSummary>, detail: Option<&SessionDetail>, + formatter: DisplayFormatter<'_>, ) -> Text<'static> { let Some(session) = session else { return Text::from("No sessions in this project."); @@ -547,7 +576,7 @@ fn render_session_detail( Line::from(vec![ Span::styled("STARTED", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::raw(session.started_at_label.clone()), + Span::raw(session_started_label(session, formatter)), ]), Line::from(vec![ Span::styled("SESSION", Style::default().fg(Color::Gray)), @@ -620,32 +649,32 @@ fn render_session_detail( ), Span::raw(format!( " users={} assistant={} calls={} outputs={} images={}", - detail.all_user_turns.len(), - detail.assistant_messages, - detail.tool_calls, - detail.tool_outputs, - detail.input_images + formatter.format_usize(detail.all_user_turns.len()), + formatter.format_usize(detail.assistant_messages), + formatter.format_usize(detail.tool_calls), + formatter.format_usize(detail.tool_outputs), + formatter.format_usize(detail.input_images) )), ])); if let Some(total_tokens) = detail.total_tokens { lines.push(Line::from(vec![ Span::styled("TOKENS", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::raw(total_tokens.to_string()), + Span::raw(formatter.format_count(total_tokens)), ])); } if let Some(input_tokens) = detail.input_tokens { lines.push(Line::from(vec![ Span::styled("INPUT", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::raw(input_tokens.to_string()), + Span::raw(formatter.format_count(input_tokens)), ])); } if let Some(output_tokens) = detail.output_tokens { lines.push(Line::from(vec![ Span::styled("OUTPUT", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::raw(output_tokens.to_string()), + Span::raw(formatter.format_count(output_tokens)), ])); } if detail.reasoning_encrypted { @@ -679,7 +708,26 @@ fn render_session_detail( Text::from(lines) } -fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &BrowserState) { +fn session_started_label(session: &SessionSummary, formatter: DisplayFormatter<'_>) -> String { + if formatter.style() == DisplayStyle::Classic { + return session.started_at_label.clone(); + } + let Some(raw) = session.started_at_raw.as_deref() else { + return session.started_at_label.clone(); + }; + let Some(parsed) = DateTime::parse_from_rfc3339(raw).ok() else { + return session.started_at_label.clone(); + }; + let local = parsed.with_timezone(&Local); + formatter.format_session_datetime(local.naive_local()) +} + +fn render_footer( + frame: &mut Frame<'_>, + area: Rect, + state: &BrowserState, + formatter: DisplayFormatter<'_>, +) { let base = match state.view { ViewMode::Projects => { "Projects: up/down, mouse wheel, enter/right open, s/F2 switch, r/F5 rescan, q quit" @@ -693,12 +741,19 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &BrowserState) { .as_deref() .map(|text| truncate_single_line(text, 100)) .unwrap_or_default(); + let style = format!("style [n]: {}", formatter.style_label()); let line = if error.is_empty() { - Line::from(vec![Span::styled(base, Style::default().fg(Color::Gray))]) + Line::from(vec![ + Span::styled(base, Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled(style, Style::default().fg(Color::Gray)), + ]) } else { Line::from(vec![ Span::styled(base, Style::default().fg(Color::Gray)), Span::raw(" "), + Span::styled(style, Style::default().fg(Color::Gray)), + Span::raw(" "), Span::styled(error, Style::default().fg(Color::Red)), ]) }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 88d7a1e..af62bf1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,7 +1,8 @@ use crate::app::{ActiveScreen, AppState, ChartOrientation}; +use crate::locale::{DisplayFormatter, DisplayStyle}; use crate::usage::{ - format_compact_kmb, format_count, format_duration_compact, format_tokens_compact, ChartRange, - ProjectActivity, UsageMetric, ACTIVITY_TIMELINE_WEEKS, + format_compact_kmb, format_count, format_duration_compact, format_tokens_compact, + format_tokens_overview, ChartRange, ProjectActivity, UsageMetric, ACTIVITY_TIMELINE_WEEKS, }; use anyhow::Result; use chrono::{Datelike, Local, NaiveDate, TimeZone, Weekday}; @@ -157,7 +158,9 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { } } ActiveScreen::Read => { - crate::read::tui::render(frame, inner, &mut state.read_browser); + let system_locale = state.system_locale.clone(); + let formatter = DisplayFormatter::new(state.display_style, &system_locale); + crate::read::tui::render(frame, inner, &mut state.read_browser, formatter); } } } @@ -277,11 +280,12 @@ fn footer_error(state: &AppState) -> String { fn footer_text(state: &AppState) -> String { let hint = footer_hint(state.active_screen); + let style = format!("Style [n]: {}", state.formatter().style_label()); let err = footer_error(state); if err.is_empty() { - hint.to_string() + format!("{hint} {style}") } else { - format!("{hint} {err}") + format!("{hint} {style} {err}") } } @@ -291,11 +295,14 @@ fn footer_height(width: u16, state: &AppState) -> u16 { fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let err = footer_error(state); + let style = format!("Style [n]: {}", state.formatter().style_label()); let line = Line::from(vec![ Span::styled( footer_hint(state.active_screen), Style::default().fg(Color::Gray), ), + Span::raw(" "), + Span::styled(style, Style::default().fg(Color::Gray)), Span::raw(if err.is_empty() { "" } else { " " }), Span::styled(err, Style::default().fg(Color::Red)), ]); @@ -307,20 +314,29 @@ fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let cards_height = usage_cards_height(state, area.width); let reset_summary = reset_summary_text(state); let controls_height = usage_controls_height(reset_summary.as_deref(), area.width); + let chunks = usage_layout(area, controls_height, cards_height); + + render_usage_controls(frame, chunks[0], state, reset_summary.as_deref()); + render_usage_cards(frame, chunks[1], state); + render_usage_chart(frame, chunks[2], state); + render_top_models(frame, chunks[3], state); +} + +fn usage_layout(area: Rect, controls_height: u16, cards_height: u16) -> [Rect; 4] { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(controls_height), Constraint::Length(cards_height), - Constraint::Min(8), + // Cards must keep their measured height. `Min` has higher priority than `Length` + // in ratatui, so a short terminal would otherwise collapse the cards to borders in + // order to preserve the chart's minimum height. Let the chart consume the remaining + // space and degrade first instead. + Constraint::Fill(1), Constraint::Length(3), ]) .split(area); - - render_usage_controls(frame, chunks[0], state, reset_summary.as_deref()); - render_usage_cards(frame, chunks[1], state); - render_usage_chart(frame, chunks[2], state); - render_top_models(frame, chunks[3], state); + [chunks[0], chunks[1], chunks[2], chunks[3]] } fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &AppState) { @@ -466,6 +482,7 @@ fn render_activity_heatmaps(frame: &mut Frame<'_>, area: Rect, state: &AppState) project, state.metric, snapshot.activity_first_weekday, + state.formatter(), ); y = y.saturating_add(ACTIVITY_PROJECT_STRIDE); if y >= inner.y.saturating_add(inner.height) { @@ -491,6 +508,7 @@ fn render_project_activity_heatmap( project: &ProjectActivity, metric: UsageMetric, first_weekday: Weekday, + formatter: DisplayFormatter<'_>, ) { if area.height < ACTIVITY_PROJECT_HEIGHT || area.width <= ACTIVITY_WEEKDAY_LABEL_WIDTH { return; @@ -509,7 +527,7 @@ fn render_project_activity_heatmap( let week_offset = weeks_total.saturating_sub(weeks_visible); let grid_x = area.x.saturating_add(ACTIVITY_WEEKDAY_LABEL_WIDTH); - let header = format_activity_project_header(project, metric, area.width as usize); + let header = format_activity_project_header(project, metric, area.width as usize, formatter); let buf = frame.buffer_mut(); write_text( buf, @@ -525,9 +543,9 @@ fn render_project_activity_heatmap( project, week_offset, weeks_visible, - grid_x, + (grid_x, area.y + 1), cell_width, - area.y + 1, + formatter, ); let max_value = project @@ -546,7 +564,7 @@ fn render_project_activity_heatmap( area.x, y, ACTIVITY_WEEKDAY_LABEL_WIDTH, - activity_weekday_label(first_weekday, row), + &activity_weekday_label(first_weekday, row, formatter), Style::default().fg(Color::Gray), ); for week in 0..weeks_visible { @@ -567,15 +585,18 @@ fn render_activity_month_labels( project: &ProjectActivity, week_offset: usize, weeks_visible: usize, - grid_x: u16, + origin: (u16, u16), cell_width: u16, - y: u16, + formatter: DisplayFormatter<'_>, ) { + let (grid_x, y) = origin; let mut next_free_x = grid_x; let grid_end = grid_x.saturating_add((weeks_visible as u16).saturating_mul(cell_width)); for week in 0..weeks_visible { let absolute_week = week_offset + week; - let Some(label) = activity_month_label_for_week(project, absolute_week, week == 0) else { + let Some(label) = + activity_month_label_for_week(project, absolute_week, week == 0, formatter) + else { continue; }; let x = grid_x.saturating_add((week as u16).saturating_mul(cell_width)); @@ -587,11 +608,13 @@ fn render_activity_month_labels( buf, x, y, - (label.len() as u16).min(available), - label, + (UnicodeWidthStr::width(label.as_str()) as u16).min(available), + &label, Style::default().fg(Color::Gray), ); - next_free_x = x.saturating_add(label.len() as u16).saturating_add(1); + next_free_x = x + .saturating_add(UnicodeWidthStr::width(label.as_str()) as u16) + .saturating_add(1); } } @@ -599,18 +622,19 @@ fn activity_month_label_for_week( project: &ProjectActivity, week: usize, force: bool, -) -> Option<&'static str> { + formatter: DisplayFormatter<'_>, +) -> Option { let start = week.saturating_mul(7); let end = start.saturating_add(7).min(project.days.len()); let days = project.days.get(start..end)?; if force { let date = parse_activity_date(&days.first()?.day)?; - return Some(month_abbrev(date.month())); + return Some(formatter.abbreviated_month(date.month())); } for day in days { let date = parse_activity_date(&day.day)?; if date.day() == 1 { - return Some(month_abbrev(date.month())); + return Some(formatter.abbreviated_month(date.month())); } } None @@ -620,28 +644,11 @@ fn parse_activity_date(day: &str) -> Option { NaiveDate::parse_from_str(day, "%Y-%m-%d").ok() } -fn month_abbrev(month: u32) -> &'static str { - match month { - 1 => "Jan", - 2 => "Feb", - 3 => "Mar", - 4 => "Apr", - 5 => "May", - 6 => "Jun", - 7 => "Jul", - 8 => "Aug", - 9 => "Sep", - 10 => "Oct", - 11 => "Nov", - 12 => "Dec", - _ => "", - } -} - fn format_activity_project_header( project: &ProjectActivity, metric: UsageMetric, max_width: usize, + formatter: DisplayFormatter<'_>, ) -> String { let name = activity_project_name(&project.display_path); let active_days = project @@ -649,14 +656,17 @@ fn format_activity_project_header( .iter() .filter(|day| activity_day_value(day, metric) > 0) .count(); - let total = activity_metric_total_label(project, metric); + let total = activity_metric_total_label(project, metric, formatter); let last = project .last_activity_day .as_deref() - .map(format_activity_date_short) + .map(|day| format_activity_date_short(day, formatter)) .unwrap_or_else(|| "--".to_string()); truncate_middle( - &format!("[{name}] {active_days} days {total} last {last}"), + &format!( + "[{name}] {} days {total} last {last}", + formatter.format_usize(active_days) + ), max_width, ) } @@ -671,24 +681,34 @@ fn activity_project_name(path: &str) -> String { .to_string() } -fn activity_metric_total_label(project: &ProjectActivity, metric: UsageMetric) -> String { +fn activity_metric_total_label( + project: &ProjectActivity, + metric: UsageMetric, + formatter: DisplayFormatter<'_>, +) -> String { match metric { UsageMetric::Tokens => { let total = project.total_tokens.max(0) as u64; let out_of_cache = (project.total_tokens - project.cached_input_tokens).max(0) as u64; - let pair = format_horizontal_value(total, Some(out_of_cache), UsageMetric::Tokens, 20); + let pair = format_horizontal_value( + total, + Some(out_of_cache), + UsageMetric::Tokens, + 20, + formatter, + ); format!("{pair} tokens") } UsageMetric::Time => format_duration_compact(project.agent_time_ms), - UsageMetric::Runs => format!("{} runs", format_count(project.agent_runs)), + UsageMetric::Runs => format!("{} runs", format_count(project.agent_runs, formatter)), } } -fn format_activity_date_short(day: &str) -> String { +fn format_activity_date_short(day: &str, formatter: DisplayFormatter<'_>) -> String { let Some(date) = parse_activity_date(day) else { return day.to_string(); }; - format!("{} {}", month_abbrev(date.month()), date.day()) + formatter.format_short_date(date) } fn activity_day_value(day: &crate::usage::UsageDay, metric: UsageMetric) -> i64 { @@ -715,17 +735,20 @@ fn activity_day_cell_width(grid_width: u16, weeks_total: usize) -> u16 { } } -fn activity_weekday_label(first_weekday: Weekday, row: usize) -> &'static str { +fn activity_weekday_label( + first_weekday: Weekday, + row: usize, + formatter: DisplayFormatter<'_>, +) -> String { let monday_row = activity_weekday_row(first_weekday, Weekday::Mon); if row < monday_row || !(row - monday_row).is_multiple_of(2) { - return ""; + return String::new(); } match activity_weekday_for_row(first_weekday, row) { - Weekday::Mon => "Mon", - Weekday::Wed => "Wed", - Weekday::Fri => "Fri", - Weekday::Sun => "Sun", - _ => "", + day @ (Weekday::Mon | Weekday::Wed | Weekday::Fri | Weekday::Sun) => { + formatter.abbreviated_weekday(day) + } + _ => String::new(), } } @@ -913,6 +936,7 @@ fn uses_compact_limit_lines(card_width: u16) -> bool { } fn limits_card_content(state: &AppState, compact: bool) -> (String, Vec) { + let formatter = state.formatter(); let (value, caption1, caption2, caption3) = if !state.limits_enabled { let msg = state .limits_error @@ -921,7 +945,7 @@ fn limits_card_content(state: &AppState, compact: bool) -> (String, Vec) .unwrap_or("Limits unavailable."); ("Unavailable".to_string(), Some(msg.to_string()), None, None) } else if let Some(limits) = state.limits.as_ref() { - format_limits_compact_card_lines(limits, compact) + format_limits_compact_card_lines(limits, compact, formatter) } else { ("Loading...".to_string(), None, None, None) }; @@ -934,18 +958,24 @@ fn limits_card_content(state: &AppState, compact: bool) -> (String, Vec) } fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { + let formatter = state.formatter(); let snapshot = state.usage.as_ref(); - let totals = snapshot.map(|snapshot| snapshot.totals_view(state.metric)); + let totals = snapshot.map(|snapshot| snapshot.totals_view(state.metric, formatter)); let today = snapshot.and_then(|snapshot| snapshot.days.last()); let (limits_value, limits_captions) = limits_card_content(state, uses_compact_limit_lines(card_width)); let today_value = today - .map(|day| format!("{} tokens", format_count(day.total_tokens))) + .map(|day| { + format!( + "{} tokens", + format_tokens_overview(day.total_tokens, formatter) + ) + }) .unwrap_or_else(|| "--".to_string()); let today_captions = vec![ today - .map(|day| format!("Runs {}", format_count(day.agent_runs))) + .map(|day| format!("Runs {}", format_count(day.agent_runs, formatter))) .unwrap_or_default(), today .map(|day| format!("Time {}", format_duration_words(day.agent_time_ms))) @@ -960,10 +990,10 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { .map(|day| day.agent_runs) .sum::() }) - .map(|runs| format!("Runs {}", format_count(runs))); + .map(|runs| format!("Runs {}", format_count(runs, formatter))); let last30_runs = snapshot .map(|snapshot| snapshot.days.iter().map(|day| day.agent_runs).sum::()) - .map(|runs| format!("Runs {}", format_count(runs))); + .map(|runs| format!("Runs {}", format_count(runs, formatter))); let mut cards = Vec::with_capacity(6); cards.push(CardSpec::new(limits_value, limits_captions)); @@ -1083,14 +1113,17 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { .filter(|snapshot| !snapshot.days.is_empty()) .map(|snapshot| { let total_runs = snapshot.days.iter().map(|day| day.agent_runs).sum::(); - format_count((total_runs as f64 / snapshot.days.len() as f64).round() as i64) + format_count( + (total_runs as f64 / snapshot.days.len() as f64).round() as i64, + formatter, + ) }) .unwrap_or_else(|| "--".to_string()); let tokens7 = snapshot .map(|snapshot| { format!( "Tokens {}", - format_tokens_compact(snapshot.totals.last7_days_tokens) + format_tokens_compact(snapshot.totals.last7_days_tokens, formatter) ) }) .unwrap_or_else(|| "--".to_string()); @@ -1098,7 +1131,7 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { .map(|snapshot| { format!( "Tokens {}", - format_tokens_compact(snapshot.totals.last30_days_tokens) + format_tokens_compact(snapshot.totals.last30_days_tokens, formatter) ) }) .unwrap_or_else(|| "--".to_string()); @@ -1157,6 +1190,7 @@ fn usage_cards_height(state: &AppState, width: u16) -> u16 { } fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let formatter = state.formatter(); let card_layout = usage_card_layout(area.width); let row_heights = usage_card_row_heights(state, area.width); let two_rows = row_heights.len() > 1; @@ -1174,7 +1208,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { }; let snapshot = state.usage.as_ref(); - let totals = snapshot.map(|s| s.totals_view(state.metric)); + let totals = snapshot.map(|s| s.totals_view(state.metric, formatter)); let today = snapshot.and_then(|s| s.days.last()); // LIMITS card (live from Codex app-server). @@ -1191,17 +1225,26 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .unwrap_or("Limits unavailable."); ("Unavailable".to_string(), Some(msg.to_string()), None, None) } else if let Some(l) = state.limits.as_ref() { - format_limits_compact_card_lines(l, uses_compact_limit_lines(card_layout.min_card_width)) + format_limits_compact_card_lines( + l, + uses_compact_limit_lines(card_layout.min_card_width), + formatter, + ) } else { ("Loading...".to_string(), None, None, None) }; // TODAY card always shows Tokens / Runs / Time. let today_value = today - .map(|d| format!("{} tokens", format_count(d.total_tokens))) + .map(|d| { + format!( + "{} tokens", + format_tokens_overview(d.total_tokens, formatter) + ) + }) .unwrap_or_else(|| "--".to_string()); let today_caption1 = today - .map(|d| format!("Runs {}", format_count(d.agent_runs))) + .map(|d| format!("Runs {}", format_count(d.agent_runs, formatter))) .unwrap_or_default(); let today_caption2 = today .map(|d| format!("Time {}", format_duration_words(d.agent_time_ms))) @@ -1214,12 +1257,12 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .map(|s| s.days.iter().map(|d| d.agent_runs).sum::()) .unwrap_or(0); let last7_runs_caption = if snapshot.is_some() { - Some(format!("Runs {}", format_count(last7_runs_sum))) + Some(format!("Runs {}", format_count(last7_runs_sum, formatter))) } else { None }; let last30_runs_caption = if snapshot.is_some() { - Some(format!("Runs {}", format_count(last30_runs_sum))) + Some(format!("Runs {}", format_count(last30_runs_sum, formatter))) } else { None }; @@ -1559,7 +1602,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { }) .unwrap_or(0); let avg30_label = if snapshot.is_some() { - format_count(avg30) + format_count(avg30, formatter) } else { "--".into() }; @@ -1568,7 +1611,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .map(|s| { format!( "Tokens {}", - format_tokens_compact(s.totals.last7_days_tokens) + format_tokens_compact(s.totals.last7_days_tokens, formatter) ) }) .unwrap_or_else(|| "--".into()); @@ -1576,7 +1619,7 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .map(|s| { format!( "Tokens {}", - format_tokens_compact(s.totals.last30_days_tokens) + format_tokens_compact(s.totals.last30_days_tokens, formatter) ) }) .unwrap_or_else(|| "--".into()); @@ -1705,16 +1748,13 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { // Note: We draw bars manually to control label placement and padding. + let formatter = state.formatter(); let range_label = match state.range { ChartRange::Week => "Last 7 days", ChartRange::Month => "Last 30 days", }; - let metric_label = match state.metric { - UsageMetric::Tokens => "TOKENS", - UsageMetric::Time => "TIME", - UsageMetric::Runs => "RUNS", - }; + let metric_label = usage_chart_metric_label(state.metric); let mut block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Plain) @@ -1765,15 +1805,15 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let mut token_out_of_cache_values: Vec = Vec::with_capacity(days.len()); for day in &days { let label = match state.orientation { - ChartOrientation::Horizontal => format_day_label_weekday_mmdd(&day.day), + ChartOrientation::Horizontal => format_day_label_weekday_mmdd(&day.day, formatter), ChartOrientation::Vertical => match state.range { - ChartRange::Week => day.short_label(), + ChartRange::Week => day.short_label(formatter), ChartRange::Month => { // Prefer compact day-of-month labels for dense charts. if day.day.len() == 10 { day.day[8..10].to_string() } else { - day.short_label() + day.short_label(formatter) } } }, @@ -1858,21 +1898,14 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { if bars_area.height >= 3 && filled_h >= 2 { let text_y = bottom_y.saturating_sub(1); // 1-line bottom space let text = match state.metric { - UsageMetric::Tokens => { - let raw = value.to_string(); - if raw.len() <= w as usize { - raw - } else { - format_compact_kmb(*value, w) - } - } + UsageMetric::Tokens => format_vertical_token_value(*value, w, formatter), UsageMetric::Time => { let raw = format_minutes_hhmm(*value); if raw.len() <= w as usize { raw } else if w >= 4 { // fallback: compact minutes - format_compact_kmb(*value, w) + format_compact_kmb(*value, w, formatter) } else { String::new() } @@ -1882,7 +1915,7 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { if raw.len() <= w as usize { raw } else { - format_compact_kmb(*value, w) + format_compact_kmb(*value, w, formatter) } } }; @@ -1976,7 +2009,13 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { UsageMetric::Tokens => Some(visible_out_of_cache[idx]), _ => None, }; - let s = format_horizontal_value(*v, out_of_cache, state.metric, u16::MAX); + let s = format_horizontal_value( + *v, + out_of_cache, + state.metric, + u16::MAX, + formatter, + ); max_len = max_len.max(s.len()); } (max_len as u16).clamp(6, 32) @@ -2078,8 +2117,13 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { UsageMetric::Tokens => Some(visible_out_of_cache[idx]), _ => None, }; - let value_text = - format_horizontal_value(*value, out_of_cache, state.metric, value_max_width); + let value_text = format_horizontal_value( + *value, + out_of_cache, + state.metric, + value_max_width, + formatter, + ); let value_text = truncate_middle(&value_text, value_max_width as usize); // Right-align numeric values within the dedicated value column. let start_x = value_area @@ -2100,23 +2144,39 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { } } -fn format_day_label_weekday_mmdd(day: &str) -> String { +fn usage_chart_metric_label(metric: UsageMetric) -> &'static str { + match metric { + UsageMetric::Tokens => "TOKENS (TOTAL / NON-CACHED)", + UsageMetric::Time => "TIME", + UsageMetric::Runs => "RUNS", + } +} + +fn format_vertical_token_value( + value: u64, + max_width: u16, + formatter: DisplayFormatter<'_>, +) -> String { + let preferred = match formatter.style() { + DisplayStyle::Classic => value.to_string(), + DisplayStyle::SystemCompact => format_tokens_compact(value as i64, formatter), + DisplayStyle::SystemFull => formatter.format_u64(value), + }; + if preferred.len() <= max_width as usize || formatter.style() == DisplayStyle::SystemFull { + preferred + } else { + format_compact_kmb(value, max_width, formatter) + } +} + +fn format_day_label_weekday_mmdd(day: &str, formatter: DisplayFormatter<'_>) -> String { // Input: YYYY-MM-DD // Output: Mon 02/02 let parsed = NaiveDate::parse_from_str(day, "%Y-%m-%d").ok(); let Some(date) = parsed else { return day.to_string(); }; - let weekday = match date.weekday().number_from_monday() { - 1 => "Mon", - 2 => "Tue", - 3 => "Wed", - 4 => "Thu", - 5 => "Fri", - 6 => "Sat", - _ => "Sun", - }; - format!("{weekday} {:02}/{:02}", date.month(), date.day()) + formatter.format_chart_day(date) } fn format_horizontal_value( @@ -2124,6 +2184,7 @@ fn format_horizontal_value( out_of_cache_tokens: Option, metric: UsageMetric, max_width: u16, + formatter: DisplayFormatter<'_>, ) -> String { if max_width == 0 { return String::new(); @@ -2131,24 +2192,19 @@ fn format_horizontal_value( if metric == UsageMetric::Tokens { if let Some(out_of_cache) = out_of_cache_tokens { - let full = format!( - "{} / {}", - format_count(value as i64), - format_count(out_of_cache as i64) - ); - if full.len() <= max_width as usize { - return full; - } - let compact = format!( "{} / {}", - format_tokens_compact(value as i64), - format_tokens_compact(out_of_cache as i64) + format_tokens_overview(value as i64, formatter), + format_tokens_overview(out_of_cache as i64, formatter) ); if compact.len() <= max_width as usize { return compact; } + if formatter.style() == DisplayStyle::SystemFull { + return compact; + } + let pair_width = max_width.saturating_sub(1); if pair_width >= 2 { let mut left_w = ((pair_width as usize * 2) / 3) as u16; @@ -2156,27 +2212,30 @@ fn format_horizontal_value( let right_w = pair_width.saturating_sub(left_w); return format!( "{} / {}", - format_compact_kmb(value, left_w), - format_compact_kmb(out_of_cache, right_w), + format_compact_kmb(value, left_w, formatter), + format_compact_kmb(out_of_cache, right_w, formatter), ); } } } let full = match metric { - UsageMetric::Tokens => format_count(value as i64), + UsageMetric::Tokens => format_tokens_overview(value as i64, formatter), UsageMetric::Time => format_minutes_hhmm(value), - UsageMetric::Runs => format_count(value as i64), + UsageMetric::Runs => format_count(value as i64, formatter), }; if full.len() <= max_width as usize { return full; } + if metric == UsageMetric::Tokens && formatter.style() == DisplayStyle::SystemFull { + return full; + } // If we can't fit the full value, try compact with the same suffix. // (No suffix for horizontal values; the chart header indicates the unit.) // Final fallback: compact only. - format_compact_kmb(value, max_width) + format_compact_kmb(value, max_width, formatter) } fn format_duration_words(ms: i64) -> String { @@ -2207,6 +2266,7 @@ fn format_minutes_hhmm(total_minutes: u64) -> String { } fn render_top_models(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let formatter = state.formatter(); let snapshot = state.usage.as_ref(); let models = snapshot.map(|s| s.top_models.clone()).unwrap_or_default(); @@ -2222,7 +2282,11 @@ fn render_top_models(frame: &mut Frame<'_>, area: Rect, state: &AppState) { spans.push(Span::raw(" ")); } spans.push(Span::styled( - format!("{} {:.1}%", truncate_middle(&m.model, 18), m.share_percent), + format!( + "{} {}%", + truncate_middle(&m.model, 18), + formatter.format_one_decimal(m.share_percent) + ), Style::default().add_modifier(Modifier::BOLD), )); } @@ -2256,6 +2320,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" Tab - toggle statistic (Tokens/Time/Runs)"), Line::from(" w - toggle timeframe (Week/Month)"), Line::from(" f - toggle layout (Horz/Vert)"), + Line::from(" n - cycle display style (Classic/System Compact/Full)"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), @@ -2266,6 +2331,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" Tab - toggle statistic (Tokens/Time/Runs)"), Line::from(" +/= - show more projects"), Line::from(" - - show fewer projects"), + Line::from(" n - cycle display style (Classic/System Compact/Full)"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), @@ -2274,11 +2340,16 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) ActiveScreen::LimitResets => Text::from(vec![ Line::from("Keys:"), Line::from(" r/F5 - refresh reset credits"), + Line::from(" n - cycle display style (Classic/System Compact/Full)"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), Line::from(" q/Esc - quit"), ]), - ActiveScreen::Read => Text::from(vec![Line::from("Keys:"), Line::from(" q/Esc - quit")]), + ActiveScreen::Read => Text::from(vec![ + Line::from("Keys:"), + Line::from(" n - cycle display style (Classic/System Compact/Full)"), + Line::from(" q/Esc - quit"), + ]), }; frame.render_widget( Paragraph::new(text) @@ -2449,29 +2520,32 @@ fn wrapped_line_count(text: &str, width: u16) -> u16 { total.max(1) } -fn reset_credit_expiration_label(expires_at: i64) -> Option { +fn reset_credit_expiration_label( + expires_at: i64, + formatter: DisplayFormatter<'_>, +) -> Option { let ms = crate::codex_rpc::normalize_epoch_millis(expires_at); let dt = Local.timestamp_millis_opt(ms).single()?; - Some(format!( - "{} {}, {}", - dt.day(), - dt.format("%b"), - dt.format("%H:%M") - )) + Some(formatter.format_reset_datetime(dt.naive_local())) } fn reset_summary_text(state: &AppState) -> Option { - reset_summary_for_limits(state.limits.as_ref()?) + reset_summary_for_limits(state.limits.as_ref()?, state.formatter()) } -fn reset_summary_for_limits(limits: &crate::codex_rpc::AccountRateLimits) -> Option { +fn reset_summary_for_limits( + limits: &crate::codex_rpc::AccountRateLimits, + formatter: DisplayFormatter<'_>, +) -> Option { let available = limits.reset_credits_available?; - let mut text = format!("Resets: {available} available"); + let mut text = format!("Resets: {} available", formatter.format_count(available)); let earliest_expiry = limits .reset_credits .as_deref() .and_then(|credits| credits.iter().filter_map(|credit| credit.expires_at).min()); - if let Some(expiry) = earliest_expiry.and_then(reset_credit_expiration_label) { + if let Some(expiry) = + earliest_expiry.and_then(|expires_at| reset_credit_expiration_label(expires_at, formatter)) + { text.push_str(" | earliest expires "); text.push_str(&expiry); } @@ -2518,18 +2592,13 @@ fn render_limit_resets(frame: &mut Frame<'_>, area: Rect, state: &AppState) { render_limit_reset_details(frame, chunks[1], state); } -fn reset_credit_date_time_label(timestamp: i64) -> Option { +fn reset_credit_date_time_label(timestamp: i64, formatter: DisplayFormatter<'_>) -> Option { let ms = crate::codex_rpc::normalize_epoch_millis(timestamp); let dt = Local.timestamp_millis_opt(ms).single()?; if dt.date_naive() == Local::now().date_naive() { - Some(format!("today {}", dt.format("%H:%M"))) + Some(format!("today {}", formatter.format_time(dt.naive_local()))) } else { - Some(format!( - "{} {}, {}", - dt.day(), - dt.format("%b"), - dt.format("%H:%M") - )) + Some(formatter.format_reset_datetime(dt.naive_local())) } } @@ -2576,7 +2645,7 @@ fn render_limit_reset_details(frame: &mut Frame<'_>, area: Rect, state: &AppStat "No reset credits are currently available.", Style::default().fg(Color::Gray), ))), - Some(credits) => reset_credit_details_text(credits), + Some(credits) => reset_credit_details_text(credits, state.formatter()), }, } }; @@ -2590,7 +2659,10 @@ fn render_limit_reset_details(frame: &mut Frame<'_>, area: Rect, state: &AppStat ); } -fn reset_credit_details_text(credits: &[crate::codex_rpc::RateLimitResetCredit]) -> Text<'static> { +fn reset_credit_details_text( + credits: &[crate::codex_rpc::RateLimitResetCredit], + formatter: DisplayFormatter<'_>, +) -> Text<'static> { let mut lines = Vec::with_capacity(credits.len().saturating_mul(4)); for (index, credit) in credits.iter().enumerate() { if index > 0 { @@ -2610,12 +2682,12 @@ fn reset_credit_details_text(credits: &[crate::codex_rpc::RateLimitResetCredit]) let status = credit.status.as_deref().unwrap_or("Unknown"); let expires = credit .expires_at - .and_then(reset_credit_date_time_label) + .and_then(|timestamp| reset_credit_date_time_label(timestamp, formatter)) .map(|value| format!("expires {value}")) .unwrap_or_else(|| "expiration unavailable".to_string()); let granted = credit .granted_at - .and_then(reset_credit_date_time_label) + .and_then(|timestamp| reset_credit_date_time_label(timestamp, formatter)) .map(|value| format!("granted {value}")); let reset_type = credit.reset_type.as_deref(); let mut metadata = format!("{status} | {expires}"); @@ -2672,30 +2744,32 @@ fn percent_left_value(used_percent: Option) -> String { format!("{}%", left.round() as i64) } -fn resets_label(resets_at: Option) -> Option { +fn resets_label(resets_at: Option, formatter: DisplayFormatter<'_>) -> Option { let raw = resets_at?; let ms = crate::codex_rpc::normalize_epoch_millis(raw); let dt = Local.timestamp_millis_opt(ms).single()?; let today = Local::now().date_naive(); let day = dt.date_naive(); - let time = dt.format("%H:%M").to_string(); + let time = formatter.format_time(dt.naive_local()); if day == today { Some(format!("resets {time}")) } else { - let month = dt.format("%b").to_string(); - Some(format!("resets {time}, {} {month}", day.day())) + Some(format!( + "resets {time}, {}", + formatter.format_reset_date(day) + )) } } -fn reset_compact_label(resets_at: Option) -> Option { +fn reset_compact_label(resets_at: Option, formatter: DisplayFormatter<'_>) -> Option { let raw = resets_at?; let ms = crate::codex_rpc::normalize_epoch_millis(raw); let dt = Local.timestamp_millis_opt(ms).single()?; let today = Local::now().date_naive(); if dt.date_naive() == today { - Some(dt.format("%H:%M").to_string()) + Some(formatter.format_time(dt.naive_local())) } else { - Some(format!("{} {}", dt.day(), dt.format("%b"))) + Some(formatter.format_reset_date(dt.date_naive())) } } @@ -2703,6 +2777,7 @@ fn format_limit_compact_line( label_with_colon: &str, window: Option<&crate::codex_rpc::RateLimitWindow>, compact: bool, + formatter: DisplayFormatter<'_>, ) -> String { // Match requested alignment: // 5h limit: 100% (resets 20:43) @@ -2718,12 +2793,12 @@ fn format_limit_compact_line( .trim_end_matches(':') .strip_suffix(" limit") .unwrap_or(label_with_colon.trim_end_matches(':')); - let reset = reset_compact_label(w.resets_at) + let reset = reset_compact_label(w.resets_at, formatter) .map(|value| format!(" | {value}")) .unwrap_or_default(); return format!("{label}: {pct}{reset}"); } - let resets = resets_label(w.resets_at) + let resets = resets_label(w.resets_at, formatter) .map(|s| format!(" ({s})")) .unwrap_or_default(); format!("{label}{pct}{resets}") @@ -2733,33 +2808,42 @@ fn format_rolling_limit_lines( primary: Option<&crate::codex_rpc::RateLimitWindow>, secondary: Option<&crate::codex_rpc::RateLimitWindow>, compact: bool, + formatter: DisplayFormatter<'_>, ) -> (String, String) { let primary_label = primary .and_then(|w| w.window_duration_mins) .and_then(format_window_label) .map(|w| format!("{w} limit:")) .unwrap_or_else(|| "5h limit:".to_string()); - let l1 = format_limit_compact_line(&primary_label, primary, compact); - let l2 = format_limit_compact_line(if compact { "7d:" } else { "Weekly:" }, secondary, compact); + let l1 = format_limit_compact_line(&primary_label, primary, compact, formatter); + let l2 = format_limit_compact_line( + if compact { "7d:" } else { "Weekly:" }, + secondary, + compact, + formatter, + ); (l1, l2) } fn format_limits_compact_card_lines( l: &crate::codex_rpc::AccountRateLimits, compact: bool, + formatter: DisplayFormatter<'_>, ) -> (String, Option, Option, Option) { let (rolling_primary, rolling_secondary) = rolling_windows_for_limits(l); let (primary, secondary) = - format_rolling_limit_lines(rolling_primary, rolling_secondary, compact); - if let Some((monthly, used)) = format_individual_limit_compact_lines(l, compact) { + format_rolling_limit_lines(rolling_primary, rolling_secondary, compact, formatter); + if let Some((monthly, used)) = format_individual_limit_compact_lines(l, compact, formatter) { return (monthly, Some(used), Some(primary), Some(secondary)); } - if let Some(extra) = format_extra_bucket_compact_line(l, compact) { - let credits = format_credits_compact_line(l).unwrap_or_else(|| "Credits: --".to_string()); + if let Some(extra) = format_extra_bucket_compact_line(l, compact, formatter) { + let credits = + format_credits_compact_line(l, formatter).unwrap_or_else(|| "Credits: --".to_string()); (primary, Some(secondary), Some(extra), Some(credits)) } else { - let credits = format_credits_compact_line(l).unwrap_or_else(|| "Credits: --".to_string()); + let credits = + format_credits_compact_line(l, formatter).unwrap_or_else(|| "Credits: --".to_string()); (primary, Some(secondary), Some(credits), None) } } @@ -2783,6 +2867,7 @@ fn rolling_windows_for_limits( fn format_individual_limit_compact_lines( l: &crate::codex_rpc::AccountRateLimits, compact: bool, + formatter: DisplayFormatter<'_>, ) -> Option<(String, String)> { let individual_limit = l.individual_limit.as_ref().or_else(|| { l.buckets @@ -2797,11 +2882,11 @@ fn format_individual_limit_compact_lines( .map(|v| format!("{}%", v.round() as i64)) .unwrap_or_else(|| "--%".to_string()); let resets = if compact { - reset_compact_label(individual_limit.resets_at) + reset_compact_label(individual_limit.resets_at, formatter) .map(|value| format!(" | {value}")) .unwrap_or_default() } else { - resets_label(individual_limit.resets_at) + resets_label(individual_limit.resets_at, formatter) .map(|value| format!(" ({value})")) .unwrap_or_default() }; @@ -2810,12 +2895,12 @@ fn format_individual_limit_compact_lines( let used = individual_limit .used .as_deref() - .map(format_credit_amount) + .map(|raw| format_credit_amount(raw, formatter)) .unwrap_or_else(|| "--".to_string()); let limit = individual_limit .limit .as_deref() - .map(format_credit_amount) + .map(|raw| format_credit_amount(raw, formatter)) .unwrap_or_else(|| "--".to_string()); let used_line = format!("{:, ) -> Option { let active_id = l.limit_id.as_deref(); let active_name = l.limit_name.as_deref(); @@ -2836,7 +2922,12 @@ fn format_extra_bucket_compact_line( })?; let window = bucket.primary.as_ref().or(bucket.secondary.as_ref())?; let label = compact_bucket_label(bucket); - Some(format_limit_compact_line(&label, Some(window), compact)) + Some(format_limit_compact_line( + &label, + Some(window), + compact, + formatter, + )) } fn compact_bucket_label(bucket: &crate::codex_rpc::RateLimitSnapshot) -> String { @@ -2855,16 +2946,19 @@ fn compact_bucket_label(bucket: &crate::codex_rpc::RateLimitSnapshot) -> String format!("{}:", truncate_middle(cleaned, 9)) } -fn format_credit_amount(raw: &str) -> String { +fn format_credit_amount(raw: &str, formatter: DisplayFormatter<'_>) -> String { let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect(); let cleaned = cleaned.trim(); let number = cleaned.parse::().ok().filter(|v| v.is_finite()); number - .map(|v| format_count(v.round() as i64)) + .map(|v| format_count(v.round() as i64, formatter)) .unwrap_or_else(|| truncate_middle(cleaned, 12)) } -fn format_credits_compact_line(l: &crate::codex_rpc::AccountRateLimits) -> Option { +fn format_credits_compact_line( + l: &crate::codex_rpc::AccountRateLimits, + formatter: DisplayFormatter<'_>, +) -> Option { const LABEL_W: usize = 10; let credits = l.credits.as_ref()?; if !credits.has_credits { @@ -2884,7 +2978,7 @@ fn format_credits_compact_line(l: &crate::codex_rpc::AccountRateLimits) -> Optio } let n = cleaned.parse::().ok().filter(|v| v.is_finite()); let amount = n - .map(|v| format!("{} credits", v.round() as i64)) + .map(|v| format!("{} credits", formatter.format_count(v.round() as i64))) .unwrap_or_else(|| format!("{cleaned} credits")); Some(format!("{: Rect { mod tests { use super::*; + #[test] + fn token_chart_header_explains_the_slash_pair() { + assert_eq!( + usage_chart_metric_label(UsageMetric::Tokens), + "TOKENS (TOTAL / NON-CACHED)" + ); + } + #[test] fn truncate_middle_is_unicode_safe_and_strips_control_chars() { let input = "ab\x1b[31mcd\x1b[0m-zu\u{0308}rich"; @@ -3023,6 +3125,17 @@ mod tests { assert_eq!(card_row_height(&[short, tall], width), expected); } + #[test] + fn short_usage_layout_preserves_measured_card_height() { + let area = Rect::new(0, 0, 140, 18); + let chunks = usage_layout(area, 2, 12); + + assert_eq!(chunks[0].height, 2); + assert_eq!(chunks[1].height, 12); + assert_eq!(chunks[2].height, 1); + assert_eq!(chunks[3].height, 3); + } + #[test] fn card_format_density_uses_the_actual_card_width() { let six_columns = usage_card_layout(180); @@ -3045,6 +3158,8 @@ mod tests { #[test] fn reset_summary_uses_earliest_returned_expiration() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); let limits = crate::codex_rpc::AccountRateLimits { limit_id: None, limit_name: None, @@ -3076,7 +3191,7 @@ mod tests { ]), }; - let summary = reset_summary_for_limits(&limits).expect("reset summary"); + let summary = reset_summary_for_limits(&limits, formatter).expect("reset summary"); assert!(summary.starts_with("Resets: 3 available | earliest expires ")); assert!(summary.contains(", ")); } @@ -3090,6 +3205,8 @@ mod tests { #[test] fn limits_card_uses_monthly_and_named_rolling_bucket() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); let limits = crate::codex_rpc::AccountRateLimits { limit_id: Some("codex".to_string()), limit_name: None, @@ -3123,34 +3240,119 @@ mod tests { }; let (value, caption1, caption2, caption3) = - format_limits_compact_card_lines(&limits, false); + format_limits_compact_card_lines(&limits, false, formatter); assert_eq!(value, "Monthly: 99%"); assert_eq!(caption1.as_deref(), Some("Credits: 564/60,000 used")); assert_eq!(caption2.as_deref(), Some("5h limit: 100%")); assert_eq!(caption3.as_deref(), Some("Weekly: 100%")); let (_, _, compact_primary, compact_secondary) = - format_limits_compact_card_lines(&limits, true); + format_limits_compact_card_lines(&limits, true, formatter); assert_eq!(compact_primary.as_deref(), Some("5h: 100%")); assert_eq!(compact_secondary.as_deref(), Some("7d: 100%")); } #[test] - fn horizontal_tokens_show_total_and_out_of_cache() { - let out = - format_horizontal_value(45_456_785, Some(1_756_241), UsageMetric::Tokens, u16::MAX); + fn classic_horizontal_tokens_keep_full_total_and_non_cached() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); + let out = format_horizontal_value( + 45_456_785, + Some(1_756_241), + UsageMetric::Tokens, + u16::MAX, + formatter, + ); assert_eq!(out, "45,456,785 / 1,756,241"); } + #[test] + fn system_horizontal_tokens_use_compact_total_and_non_cached() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = + DisplayFormatter::new(crate::locale::DisplayStyle::SystemCompact, &system_locale); + let out = format_horizontal_value( + 45_456_785, + Some(1_756_241), + UsageMetric::Tokens, + u16::MAX, + formatter, + ); + assert_eq!(out, "45.46M / 1.76M"); + } + + #[test] + fn vertical_token_values_compact_only_in_system_mode() { + let system_locale = crate::locale::SystemLocale::default(); + let classic = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); + let system = + DisplayFormatter::new(crate::locale::DisplayStyle::SystemCompact, &system_locale); + + assert_eq!( + format_vertical_token_value(45_456_785, 20, classic), + "45456785" + ); + assert_eq!( + format_vertical_token_value(45_456_785, 20, system), + "45.46M" + ); + } + + #[test] + fn system_full_keeps_chart_token_values_expanded() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = + DisplayFormatter::new(crate::locale::DisplayStyle::SystemFull, &system_locale); + + assert_eq!( + format_horizontal_value( + 45_456_785, + Some(1_756_241), + UsageMetric::Tokens, + u16::MAX, + formatter, + ), + "45456785 / 1756241" + ); + assert_eq!( + format_vertical_token_value(45_456_785, 20, formatter), + "45456785" + ); + assert_eq!( + format_horizontal_value( + 45_456_785, + Some(1_756_241), + UsageMetric::Tokens, + 8, + formatter, + ), + "45456785 / 1756241" + ); + assert_eq!( + format_vertical_token_value(45_456_785, 4, formatter), + "45456785" + ); + } + #[test] fn horizontal_tokens_pair_compacts_when_tight() { - let out = format_horizontal_value(45_456_785, Some(1_756_241), UsageMetric::Tokens, 10); + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); + let out = format_horizontal_value( + 45_456_785, + Some(1_756_241), + UsageMetric::Tokens, + 10, + formatter, + ); assert!(out.contains(" / ")); assert!(!out.is_empty()); } #[test] fn project_activity_tokens_show_total_and_out_of_cache() { + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); let project = ProjectActivity { display_path: "/tmp/SFM".to_string(), days: Vec::new(), @@ -3162,7 +3364,7 @@ mod tests { }; assert_eq!( - activity_metric_total_label(&project, UsageMetric::Tokens), + activity_metric_total_label(&project, UsageMetric::Tokens, formatter), "250 / 100 tokens" ); } @@ -3189,11 +3391,13 @@ mod tests { #[test] fn activity_weekday_labels_follow_first_weekday() { - assert_eq!(activity_weekday_label(Weekday::Mon, 0), "Mon"); - assert_eq!(activity_weekday_label(Weekday::Mon, 6), "Sun"); - assert_eq!(activity_weekday_label(Weekday::Sun, 0), ""); - assert_eq!(activity_weekday_label(Weekday::Sun, 1), "Mon"); - assert_eq!(activity_weekday_label(Weekday::Sun, 6), ""); - assert_eq!(activity_weekday_label(Weekday::Sat, 2), "Mon"); + let system_locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(crate::locale::DisplayStyle::Classic, &system_locale); + assert_eq!(activity_weekday_label(Weekday::Mon, 0, formatter), "Mon"); + assert_eq!(activity_weekday_label(Weekday::Mon, 6, formatter), "Sun"); + assert_eq!(activity_weekday_label(Weekday::Sun, 0, formatter), ""); + assert_eq!(activity_weekday_label(Weekday::Sun, 1, formatter), "Mon"); + assert_eq!(activity_weekday_label(Weekday::Sun, 6, formatter), ""); + assert_eq!(activity_weekday_label(Weekday::Sat, 2, formatter), "Mon"); } } diff --git a/src/usage/mod.rs b/src/usage/mod.rs index 2fcfa8a..925ce22 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -1,3 +1,4 @@ +use crate::locale::{DisplayFormatter, DisplayStyle}; use anyhow::{Context, Result}; use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Utc, Weekday}; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; @@ -62,7 +63,7 @@ pub enum ChartRange { Month, } -pub fn format_compact_kmb(value: u64, max_width: u16) -> String { +pub fn format_compact_kmb(value: u64, max_width: u16, formatter: DisplayFormatter<'_>) -> String { // Examples (depending on width): // 1234 -> 1.23K / 1.2K / 1K // 12_345_678 -> 12.35M / 12.3M / 12M @@ -71,7 +72,7 @@ pub fn format_compact_kmb(value: u64, max_width: u16) -> String { return String::new(); } if value < 1000 { - let s = value.to_string(); + let s = formatter.format_u64(value); return if s.len() <= max_width as usize { s } else { @@ -94,7 +95,7 @@ pub fn format_compact_kmb(value: u64, max_width: u16) -> String { // In dense layouts, force integer suffixes (e.g. 27M instead of 27.4M). // Heuristic: if the label width is <= 5 cells, decimals tend to hurt readability. if max_width <= 5 { - let s = format_compact_scaled(scaled, suffix, 0); + let s = format_compact_scaled(scaled, suffix, 0, formatter); return if s.len() <= max_width as usize { s } else { @@ -105,14 +106,14 @@ pub fn format_compact_kmb(value: u64, max_width: u16) -> String { // Prefer 2 decimals, then reduce precision if it doesn't fit. for decimals in [2usize, 1usize, 0usize] { - let s = format_compact_scaled(scaled, suffix, decimals); + let s = format_compact_scaled(scaled, suffix, decimals, formatter); if s.len() <= max_width as usize { return s; } } // Final fallback: "1K/M/B/T" - let s = format!("{:.0}{suffix}", scaled.max(0.0).round()); + let s = formatter.localize_decimal(&format!("{:.0}{suffix}", scaled.max(0.0).round())); if s.len() <= max_width as usize { return s; } @@ -121,7 +122,12 @@ pub fn format_compact_kmb(value: u64, max_width: u16) -> String { s[..max_width as usize].to_string() } -fn format_compact_scaled(value: f64, suffix: &str, decimals: usize) -> String { +fn format_compact_scaled( + value: f64, + suffix: &str, + decimals: usize, + formatter: DisplayFormatter<'_>, +) -> String { // Format with fixed decimals, then trim trailing zeros and a trailing dot. let mut s = format!("{:.*}", decimals, value.max(0.0)); if decimals > 0 { @@ -133,7 +139,7 @@ fn format_compact_scaled(value: f64, suffix: &str, decimals: usize) -> String { } } s.push_str(suffix); - s + formatter.localize_decimal(&s) } #[derive(Debug, Clone)] @@ -147,30 +153,19 @@ pub struct UsageDay { } impl UsageDay { - pub fn short_label(&self) -> String { - // Expect YYYY-MM-DD - if self.day.len() == 10 { - let month = &self.day[5..7]; - let day = &self.day[8..10]; - let month_name = match month { - "01" => "Jan", - "02" => "Feb", - "03" => "Mar", - "04" => "Apr", - "05" => "May", - "06" => "Jun", - "07" => "Jul", - "08" => "Aug", - "09" => "Sep", - "10" => "Oct", - "11" => "Nov", - "12" => "Dec", - _ => month, - }; - return format!("{month_name} {day}"); + pub fn short_label(&self, formatter: DisplayFormatter<'_>) -> String { + format_day_short(&self.day, formatter) + } +} + +fn format_day_short(day: &str, formatter: DisplayFormatter<'_>) -> String { + // Expect YYYY-MM-DD + if day.len() == 10 { + if let Ok(date) = chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d") { + return formatter.format_short_date(date); } - self.day.clone() } + day.to_string() } #[derive(Debug, Clone)] @@ -250,7 +245,11 @@ impl LocalUsageSnapshot { .collect() } - pub fn totals_view(&self, metric: UsageMetric) -> UsageTotalsView { + pub fn totals_view( + &self, + metric: UsageMetric, + formatter: DisplayFormatter<'_>, + ) -> UsageTotalsView { let last7 = self.last7_days(); let last7_agent_ms: i64 = last7.iter().map(|d| d.agent_time_ms).sum(); let last30_agent_ms: i64 = self.days.iter().map(|d| d.agent_time_ms).sum(); @@ -265,10 +264,10 @@ impl LocalUsageSnapshot { .max_by_key(|d| d.total_tokens) .filter(|d| d.total_tokens > 0); let peak_day = peak - .map(|d| d.short_label()) + .map(|d| d.short_label(formatter)) .unwrap_or_else(|| "--".to_string()); let peak_tokens = peak.map(|d| d.total_tokens).unwrap_or(0); - let sub = format!("{} tokens", format_tokens_compact(peak_tokens)); + let sub = format!("{} tokens", format_tokens_compact(peak_tokens, formatter)); (peak_day, peak_tokens, sub) } UsageMetric::Time => { @@ -278,7 +277,7 @@ impl LocalUsageSnapshot { .max_by_key(|d| d.agent_time_ms) .filter(|d| d.agent_time_ms > 0); let peak_day = peak - .map(|d| d.short_label()) + .map(|d| d.short_label(formatter)) .unwrap_or_else(|| "--".to_string()); let peak_ms = peak.map(|d| d.agent_time_ms).unwrap_or(0); let sub = format!("{} agent time", format_duration_compact(peak_ms)); @@ -291,10 +290,10 @@ impl LocalUsageSnapshot { .max_by_key(|d| d.agent_runs) .filter(|d| d.agent_runs > 0); let peak_day = peak - .map(|d| d.short_label()) + .map(|d| d.short_label(formatter)) .unwrap_or_else(|| "--".to_string()); let peak_runs = peak.map(|d| d.agent_runs).unwrap_or(0); - let sub = format!("{} runs", format_count(peak_runs)); + let sub = format!("{} runs", format_count(peak_runs, formatter)); (peak_day, peak_runs, sub) } }; @@ -303,20 +302,31 @@ impl LocalUsageSnapshot { UsageMetric::Tokens => UsageTotalsView { last7_primary_label: format!( "{} tokens", - format_tokens_compact(self.totals.last7_days_tokens) + format_tokens_compact(self.totals.last7_days_tokens, formatter) ), last30_primary_label: format!( "{} tokens", - format_tokens_compact(self.totals.last30_days_tokens) + format_tokens_compact(self.totals.last30_days_tokens, formatter) + ), + avg_primary_label: format_tokens_compact( + self.totals.average_daily_tokens, + formatter, ), - avg_primary_label: format_tokens_compact(self.totals.average_daily_tokens), - cache_label: format!("{:.1}%", self.totals.cache_hit_rate_percent), - total_label: format_count(self.totals.last30_days_tokens), - runs_label: format!("{} runs", format_count(last7_runs)), - peak_day_label: self.totals.peak_day.as_deref().unwrap_or("--").to_string(), + cache_label: format!( + "{}%", + formatter.format_one_decimal(self.totals.cache_hit_rate_percent) + ), + total_label: format_tokens_overview(self.totals.last30_days_tokens, formatter), + runs_label: format!("{} runs", format_count(last7_runs, formatter)), + peak_day_label: self + .totals + .peak_day + .as_deref() + .map(|day| format_day_short(day, formatter)) + .unwrap_or(peak_day), peak_sub_label: format!( "{} tokens", - format_tokens_compact(self.totals.peak_day_tokens) + format_tokens_compact(self.totals.peak_day_tokens, formatter) ), }, UsageMetric::Time => { @@ -331,7 +341,7 @@ impl LocalUsageSnapshot { avg_primary_label: format_duration_compact(avg_ms), cache_label: "--".to_string(), total_label: format_duration(last30_agent_ms), - runs_label: format!("{} runs", format_count(last7_runs)), + runs_label: format!("{} runs", format_count(last7_runs, formatter)), peak_day_label: peak_day, peak_sub_label: peak_sub, } @@ -343,12 +353,12 @@ impl LocalUsageSnapshot { (last7_runs as f64 / last7.len() as f64).round() as i64 }; UsageTotalsView { - last7_primary_label: format!("{} runs", format_count(last7_runs)), - last30_primary_label: format!("{} runs", format_count(last30_runs)), - avg_primary_label: format_count(avg_7), + last7_primary_label: format!("{} runs", format_count(last7_runs, formatter)), + last30_primary_label: format!("{} runs", format_count(last30_runs, formatter)), + avg_primary_label: format_count(avg_7, formatter), cache_label: "--".to_string(), - total_label: format_count(last30_runs), - runs_label: format!("{} runs", format_count(last7_runs)), + total_label: format_count(last30_runs, formatter), + runs_label: format!("{} runs", format_count(last7_runs, formatter)), peak_day_label: peak_day, peak_sub_label: peak_sub, } @@ -2384,25 +2394,21 @@ fn days_since_week_start(day: Weekday, first_weekday: Weekday) -> i64 { (7 + day - first) % 7 } -pub fn format_count(value: i64) -> String { - let mut n = value.max(0) as u64; - if n < 1000 { - return n.to_string(); - } - let mut parts: Vec = Vec::new(); - while n >= 1000 { - parts.push(format!("{:03}", n % 1000)); - n /= 1000; +pub fn format_count(value: i64, formatter: DisplayFormatter<'_>) -> String { + formatter.format_count(value) +} + +pub fn format_tokens_overview(value: i64, formatter: DisplayFormatter<'_>) -> String { + match formatter.style() { + DisplayStyle::Classic | DisplayStyle::SystemFull => format_count(value, formatter), + DisplayStyle::SystemCompact => format_tokens_compact(value, formatter), } - parts.push(n.to_string()); - parts.reverse(); - parts.join(",") } -pub fn format_tokens_compact(value: i64) -> String { +pub fn format_tokens_compact(value: i64, formatter: DisplayFormatter<'_>) -> String { let v = value.max(0) as u64; if v < 1000 { - return v.to_string(); + return formatter.format_u64(v); } let (div, suffix) = if v >= 1_000_000_000_000 { @@ -2415,7 +2421,7 @@ pub fn format_tokens_compact(value: i64) -> String { (1_000f64, "K") }; let scaled = (v as f64) / div; - format_compact_scaled(scaled, suffix, 2) + format_compact_scaled(scaled, suffix, 2, formatter) } pub fn format_duration_compact(ms: i64) -> String { From 1b5119ec1c299d57af6f0c58390cedf4e67151fd Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:41:55 +0300 Subject: [PATCH 2/4] feat: add mouse controls --- CHANGELOG.md | 1 + README.md | 1 + src/app/mod.rs | 91 ++++++++++++++++++++++++++++- src/ui/mod.rs | 151 ++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 236 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8048ab..9d9e34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project are documented in this file. - Added a persisted `n` shortcut to cycle display formatting through Classic, System Compact, and System Full for numbers, dates, times, and calendar labels. - Clarified token pair charts with the `TOKENS (TOTAL / NON-CACHED)` heading. - Added compact `K/M/B/T` notation for large dashboard token values in System Compact mode, including values inside vertical chart bars. System Full keeps those values expanded, while Classic output remains unchanged. +- Added mouse controls for selecting the visible statistic, chart timeframe, and chart orientation; clicking the footer cycles the display style on every screen. ## 0.3.6 - 2026-06-05 diff --git a/README.md b/README.md index 9edf7f5..a79a80a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ comon --scan-time-budget-ms 1500 --max-jsonl-line-kib 512 - `w` Toggle timeframe (Week/Month) - `f` Toggle layout (Horz/Vert) - `n` Cycle display formatting (Classic/System Compact/System Full) +- Mouse: click Tokens/Time/Runs, Week/Month, or Horz/Vert controls; click the footer to cycle display formatting - `s` / `F2` Switch between Usage and Session history - `r` / `F5` Refresh current screen - `?` Help overlay diff --git a/src/app/mod.rs b/src/app/mod.rs index 01a4b40..c36fefb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,8 +3,9 @@ use crate::locale::{DisplayFormatter, DisplayStyle, SystemLocale}; use crate::read; use crate::usage::{ChartRange, LocalUsageSnapshot, UsageMetric}; use anyhow::{anyhow, Context, Result}; -use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; use ratatui::Terminal; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -70,6 +71,29 @@ pub(crate) enum ActiveScreen { Read, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UiClickAction { + SetMetric(UsageMetric), + SetRange(ChartRange), + SetOrientation(ChartOrientation), + CycleDisplayStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct UiHitTarget { + pub(crate) area: Rect, + pub(crate) action: UiClickAction, +} + +impl UiHitTarget { + fn contains(self, column: u16, row: u16) -> bool { + column >= self.area.x + && column < self.area.x.saturating_add(self.area.width) + && row >= self.area.y + && row < self.area.y.saturating_add(self.area.height) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputOutcome { Continue(bool), @@ -113,6 +137,7 @@ pub(crate) struct AppState { pub(crate) no_sessions_confirm_dismissed: bool, pub(crate) display_style: DisplayStyle, pub(crate) system_locale: SystemLocale, + pub(crate) ui_hit_targets: Vec, pub(crate) usage: Option, pub(crate) usage_updated_at: Option, @@ -453,6 +478,7 @@ async fn run_inner( no_sessions_confirm_dismissed: restored_ui_state.no_sessions_confirm_dismissed, display_style: restored_ui_state.display_style, system_locale: config.system_locale.clone(), + ui_hit_targets: Vec::new(), usage: None, usage_updated_at: None, usage_error: None, @@ -574,6 +600,19 @@ fn handle_input_event( usage_refresh_tx: &mpsc::Sender<()>, limits_refresh_tx: &mpsc::Sender<()>, ) -> Result { + if let Event::Mouse(mouse) = &event { + if state.show_help || state.no_sessions_confirm_open { + return Ok(InputOutcome::Continue(false)); + } + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some(action) = ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) + { + let changed = apply_ui_click_action(state, action); + return Ok(InputOutcome::Continue(changed)); + } + } + } + if let Event::Key(key) = &event { if key.kind != KeyEventKind::Press { return Ok(InputOutcome::Continue(false)); @@ -637,6 +676,37 @@ fn handle_input_event( } } +fn ui_click_action_at(targets: &[UiHitTarget], column: u16, row: u16) -> Option { + targets + .iter() + .find(|target| target.contains(column, row)) + .map(|target| target.action) +} + +fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { + match action { + UiClickAction::SetMetric(metric) => { + let changed = state.metric != metric; + state.metric = metric; + changed + } + UiClickAction::SetRange(range) => { + let changed = state.range != range; + state.range = range; + changed + } + UiClickAction::SetOrientation(orientation) => { + let changed = state.orientation != orientation; + state.orientation = orientation; + changed + } + UiClickAction::CycleDisplayStyle => { + state.display_style = state.display_style.toggled(); + true + } + } +} + fn map_event_to_usage_cmd(event: Event) -> Option { match event { Event::Key(key) => { @@ -1050,6 +1120,25 @@ mod tests { static TEMP_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + #[test] + fn ui_hit_targets_include_left_top_and_exclude_right_bottom_edges() { + let targets = [UiHitTarget { + area: Rect::new(10, 5, 4, 2), + action: UiClickAction::SetMetric(UsageMetric::Runs), + }]; + + assert_eq!( + ui_click_action_at(&targets, 10, 5), + Some(UiClickAction::SetMetric(UsageMetric::Runs)) + ); + assert_eq!( + ui_click_action_at(&targets, 13, 6), + Some(UiClickAction::SetMetric(UsageMetric::Runs)) + ); + assert_eq!(ui_click_action_at(&targets, 14, 5), None); + assert_eq!(ui_click_action_at(&targets, 10, 7), None); + } + fn make_temp_dir(prefix: &str) -> PathBuf { let unique = format!( "{}-{}-{}", diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bcf0ff2..025ea8c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,4 @@ -use crate::app::{ActiveScreen, AppState, ChartOrientation}; +use crate::app::{ActiveScreen, AppState, ChartOrientation, UiClickAction, UiHitTarget}; use crate::locale::{DisplayFormatter, DisplayStyle}; use crate::usage::{ format_compact_kmb, format_count, format_duration_compact, format_tokens_compact, @@ -71,6 +71,7 @@ pub fn format_updated_label(updated_at: Instant) -> String { } pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { + state.ui_hit_targets.clear(); let area = frame.area(); let title = match state.active_screen { ActiveScreen::Usage => " comon :: usage ", @@ -158,6 +159,15 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { } } ActiveScreen::Read => { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(2), + ]) + .split(inner); + register_hit_target(state, chunks[2], UiClickAction::CycleDisplayStyle); let system_locale = state.system_locale.clone(); let formatter = DisplayFormatter::new(state.display_style, &system_locale); crate::read::tui::render(frame, inner, &mut state.read_browser, formatter); @@ -293,7 +303,7 @@ fn footer_height(width: u16, state: &AppState) -> u16 { wrapped_line_count(&footer_text(state), width).max(1) } -fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &AppState) { +fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { let err = footer_error(state); let style = format!("Style [n]: {}", state.formatter().style_label()); let line = Line::from(vec![ @@ -308,9 +318,10 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ]); frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: true }), area); + register_hit_target(state, area, UiClickAction::CycleDisplayStyle); } -fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &AppState) { +fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { let cards_height = usage_cards_height(state, area.width); let reset_summary = reset_summary_text(state); let controls_height = usage_controls_height(reset_summary.as_deref(), area.width); @@ -339,7 +350,7 @@ fn usage_layout(area: Rect, controls_height: u16, cards_height: u16) -> [Rect; 4 [chunks[0], chunks[1], chunks[2], chunks[3]] } -fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &AppState) { +fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { let cards_height = usage_cards_height(state, area.width); let reset_summary = reset_summary_text(state); let controls_height = usage_controls_height(reset_summary.as_deref(), area.width); @@ -360,7 +371,7 @@ fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &AppState) { fn render_activity_controls( frame: &mut Frame<'_>, area: Rect, - state: &AppState, + state: &mut AppState, reset_summary: Option<&str>, ) { let reset_height = reset_summary_height(reset_summary, area.width); @@ -391,6 +402,22 @@ fn render_activity_controls( let tokens = pill("TOKENS", state.metric == UsageMetric::Tokens); let time = pill("TIME", state.metric == UsageMetric::Time); let runs = pill("RUNS", state.metric == UsageMetric::Runs); + let project_limit = state.activity_project_limit.to_string(); + register_right_aligned_targets( + state, + row[1], + &[ + ("VIEW ", None), + ( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + ), + (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), + (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), + (" PROJECTS ", None), + (&project_limit, None), + ], + ); let right = Paragraph::new(Line::from(vec![ Span::styled("VIEW", Style::default().fg(Color::Gray)), Span::raw(" "), @@ -814,7 +841,7 @@ fn write_text( fn render_usage_controls( frame: &mut Frame<'_>, area: Rect, - state: &AppState, + state: &mut AppState, reset_summary: Option<&str>, ) { let reset_height = reset_summary_height(reset_summary, area.width); @@ -850,6 +877,32 @@ fn render_usage_controls( let vert = pill("VERT", state.orientation == ChartOrientation::Vertical); let horz = pill("HORZ", state.orientation == ChartOrientation::Horizontal); + register_right_aligned_targets( + state, + row[1], + &[ + ("VIEW ", None), + ( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + ), + (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), + (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), + (" GRAPH ", None), + (" WEEK ", Some(UiClickAction::SetRange(ChartRange::Week))), + (" MONTH ", Some(UiClickAction::SetRange(ChartRange::Month))), + (" BARS ", None), + ( + " VERT ", + Some(UiClickAction::SetOrientation(ChartOrientation::Vertical)), + ), + ( + " HORZ ", + Some(UiClickAction::SetOrientation(ChartOrientation::Horizontal)), + ), + ], + ); + let right = Paragraph::new(Line::from(vec![ Span::styled("VIEW", Style::default().fg(Color::Gray)), Span::raw(" "), @@ -2296,7 +2349,7 @@ fn render_top_models(frame: &mut Frame<'_>, area: Rect, state: &AppState) { fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) { let w = area.width.min(60); - let h = area.height.min(13); + let h = area.height.min(14); let popup = centered_rect(w, h, area); frame.render_widget(Clear, popup); @@ -2321,6 +2374,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" w - toggle timeframe (Week/Month)"), Line::from(" f - toggle layout (Horz/Vert)"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), + Line::from(" Mouse - click controls; click footer to cycle style"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), @@ -2332,6 +2386,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" +/= - show more projects"), Line::from(" - - show fewer projects"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), + Line::from(" Mouse - click view; click footer to cycle style"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), @@ -2341,6 +2396,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from("Keys:"), Line::from(" r/F5 - refresh reset credits"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), + Line::from(" Mouse - click footer to cycle style"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), Line::from(" q/Esc - quit"), @@ -2348,6 +2404,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) ActiveScreen::Read => Text::from(vec![ Line::from("Keys:"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), + Line::from(" Mouse - click footer to cycle style"), Line::from(" q/Esc - quit"), ]), }; @@ -2425,6 +2482,55 @@ fn pill(label: &str, active: bool) -> Span<'static> { Span::styled(format!(" {label} "), style) } +fn register_hit_target(state: &mut AppState, area: Rect, action: UiClickAction) { + if area.width > 0 && area.height > 0 { + state.ui_hit_targets.push(UiHitTarget { area, action }); + } +} + +fn register_right_aligned_targets( + state: &mut AppState, + area: Rect, + segments: &[(&str, Option)], +) { + state + .ui_hit_targets + .extend(right_aligned_targets(area, segments)); +} + +fn right_aligned_targets( + area: Rect, + segments: &[(&str, Option)], +) -> Vec { + let total_width = segments.iter().fold(0_u16, |total, (text, _)| { + total.saturating_add(UnicodeWidthStr::width(*text).min(u16::MAX as usize) as u16) + }); + if total_width > area.width || area.height == 0 { + return Vec::new(); + } + + let mut targets = Vec::new(); + let mut x = area.x.saturating_add(area.width - total_width); + for (text, action) in segments { + let width = UnicodeWidthStr::width(*text).min(u16::MAX as usize) as u16; + if let Some(action) = action { + if width > 0 { + targets.push(UiHitTarget { + area: Rect { + x, + y: area.y, + width, + height: 1, + }, + action: *action, + }); + } + } + x = x.saturating_add(width); + } + targets +} + fn card( title: &str, value: &str, @@ -3133,6 +3239,37 @@ fn inset_with_border_and_padding(area: Rect, padding: Padding) -> Rect { mod tests { use super::*; + #[test] + fn right_aligned_control_targets_follow_rendered_segments() { + let targets = right_aligned_targets( + Rect::new(10, 3, 30, 1), + &[ + ("VIEW ", None), + ( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + ), + (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), + ], + ); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].area, Rect::new(26, 3, 8, 1)); + assert_eq!(targets[1].area, Rect::new(34, 3, 6, 1)); + } + + #[test] + fn right_aligned_control_targets_are_disabled_when_clipped() { + let targets = right_aligned_targets( + Rect::new(0, 0, 4, 1), + &[( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + )], + ); + assert!(targets.is_empty()); + } + #[test] fn token_chart_header_explains_the_slash_pair() { assert_eq!( From 6c55060b98740214edc3f1b5ea790f8667f88a60 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:55:53 +0300 Subject: [PATCH 3/4] feat: frame usage controls --- CHANGELOG.md | 1 + src/ui/mod.rs | 218 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 188 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9e34e..f852a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project are documented in this file. - Clarified token pair charts with the `TOKENS (TOTAL / NON-CACHED)` heading. - Added compact `K/M/B/T` notation for large dashboard token values in System Compact mode, including values inside vertical chart bars. System Full keeps those values expanded, while Classic output remains unchanged. - Added mouse controls for selecting the visible statistic, chart timeframe, and chart orientation; clicking the footer cycles the display style on every screen. +- Grouped the Usage screen's View, Graph, and Bars controls into aligned thin-border panels. ## 0.3.6 - 2026-06-05 diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 025ea8c..53f8015 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -353,7 +353,7 @@ fn usage_layout(area: Rect, controls_height: u16, cards_height: u16) -> [Rect; 4 fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { let cards_height = usage_cards_height(state, area.width); let reset_summary = reset_summary_text(state); - let controls_height = usage_controls_height(reset_summary.as_deref(), area.width); + let controls_height = activity_controls_height(reset_summary.as_deref(), area.width); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -847,27 +847,14 @@ fn render_usage_controls( let reset_height = reset_summary_height(reset_summary, area.width); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(reset_height)]) + .constraints([Constraint::Length(3), Constraint::Length(reset_height)]) .split(area); - let row = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Min(0)]) - .split(chunks[0]); let workspace_label = state .workspace_path .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "All workspaces".to_string()); - let left = Paragraph::new(Line::from(vec![ - Span::styled("WORKSPACE", Style::default().fg(Color::Gray)), - Span::raw(" "), - Span::styled( - truncate_middle(&workspace_label, 48), - Style::default().add_modifier(Modifier::BOLD), - ), - ])); - frame.render_widget(left, row[0]); let tokens = pill("TOKENS", state.metric == UsageMetric::Tokens); let time = pill("TIME", state.metric == UsageMetric::Time); @@ -877,6 +864,152 @@ fn render_usage_controls( let vert = pill("VERT", state.orientation == ChartOrientation::Vertical); let horz = pill("HORZ", state.orientation == ChartOrientation::Horizontal); + if let Some([view_area, graph_area, bars_area]) = usage_control_group_areas(chunks[0]) { + let workspace_area = Rect { + x: chunks[0].x, + y: chunks[0].y.saturating_add(1), + width: view_area.x.saturating_sub(chunks[0].x).saturating_sub(1), + height: 1, + }; + render_workspace_label(frame, workspace_area, &workspace_label); + + render_control_group(frame, view_area, "VIEW", vec![tokens, time, runs]); + render_control_group(frame, graph_area, "GRAPH", vec![week, month]); + render_control_group(frame, bars_area, "BARS", vec![vert, horz]); + + register_group_targets( + state, + view_area, + &[ + ( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + ), + (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), + (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), + ], + ); + register_group_targets( + state, + graph_area, + &[ + (" WEEK ", Some(UiClickAction::SetRange(ChartRange::Week))), + (" MONTH ", Some(UiClickAction::SetRange(ChartRange::Month))), + ], + ); + register_group_targets( + state, + bars_area, + &[ + ( + " VERT ", + Some(UiClickAction::SetOrientation(ChartOrientation::Vertical)), + ), + ( + " HORZ ", + Some(UiClickAction::SetOrientation(ChartOrientation::Horizontal)), + ), + ], + ); + } else { + render_flat_usage_controls( + frame, + chunks[0], + state, + &workspace_label, + vec![tokens, time, runs, week, month, vert, horz], + ); + } + + if let Some(text) = reset_summary { + render_reset_summary(frame, chunks[1], text); + } +} + +fn render_workspace_label(frame: &mut Frame<'_>, area: Rect, workspace_label: &str) { + let left = Paragraph::new(Line::from(vec![ + Span::styled("WORKSPACE", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled( + truncate_middle(workspace_label, 48), + Style::default().add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(left, area); +} + +fn render_control_group( + frame: &mut Frame<'_>, + area: Rect, + title: &'static str, + options: Vec>, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title(Span::styled( + format!(" {title} "), + Style::default().fg(Color::Gray), + )); + frame.render_widget(Paragraph::new(Line::from(options)).block(block), area); +} + +fn register_group_targets( + state: &mut AppState, + area: Rect, + segments: &[(&str, Option)], +) { + let content = Rect { + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: 1, + }; + register_right_aligned_targets(state, content, segments); +} + +fn usage_control_group_areas(area: Rect) -> Option<[Rect; 3]> { + const VIEW_WIDTH: u16 = 22; + const GRAPH_WIDTH: u16 = 15; + const BARS_WIDTH: u16 = 14; + const GAP: u16 = 1; + const TOTAL_WIDTH: u16 = VIEW_WIDTH + GRAPH_WIDTH + BARS_WIDTH + GAP * 2; + + if area.width < TOTAL_WIDTH || area.height < 3 { + return None; + } + + let start_x = area.x.saturating_add(area.width - TOTAL_WIDTH); + let view = Rect::new(start_x, area.y, VIEW_WIDTH, 3); + let graph = Rect::new( + start_x.saturating_add(VIEW_WIDTH + GAP), + area.y, + GRAPH_WIDTH, + 3, + ); + let bars = Rect::new( + graph.x.saturating_add(GRAPH_WIDTH + GAP), + area.y, + BARS_WIDTH, + 3, + ); + Some([view, graph, bars]) +} + +fn render_flat_usage_controls( + frame: &mut Frame<'_>, + area: Rect, + state: &mut AppState, + workspace_label: &str, + pills: Vec>, +) { + let center = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); + let row = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Min(0)]) + .split(center); + render_workspace_label(frame, row[0], workspace_label); + register_right_aligned_targets( state, row[1], @@ -903,29 +1036,27 @@ fn render_usage_controls( ], ); - let right = Paragraph::new(Line::from(vec![ + let mut spans = vec![ Span::styled("VIEW", Style::default().fg(Color::Gray)), Span::raw(" "), - tokens, - time, - runs, + ]; + spans.extend(pills[0..3].iter().cloned()); + spans.extend([ Span::raw(" "), Span::styled("GRAPH", Style::default().fg(Color::Gray)), Span::raw(" "), - week, - month, + ]); + spans.extend(pills[3..5].iter().cloned()); + spans.extend([ Span::raw(" "), Span::styled("BARS", Style::default().fg(Color::Gray)), Span::raw(" "), - vert, - horz, - ])) - .alignment(Alignment::Right); - frame.render_widget(right, row[1]); - - if let Some(text) = reset_summary { - render_reset_summary(frame, chunks[1], text); - } + ]); + spans.extend(pills[5..7].iter().cloned()); + frame.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Right), + row[1], + ); } #[derive(Debug)] @@ -2670,6 +2801,10 @@ fn reset_summary_height(summary: Option<&str>, width: u16) -> u16 { } fn usage_controls_height(summary: Option<&str>, width: u16) -> u16 { + 3_u16.saturating_add(reset_summary_height(summary, width)) +} + +fn activity_controls_height(summary: Option<&str>, width: u16) -> u16 { 1_u16.saturating_add(reset_summary_height(summary, width)) } @@ -3270,6 +3405,26 @@ mod tests { assert!(targets.is_empty()); } + #[test] + fn usage_control_groups_are_equal_height_and_right_aligned() { + let [view, graph, bars] = + usage_control_group_areas(Rect::new(10, 4, 80, 3)).expect("framed controls"); + + assert_eq!(view, Rect::new(37, 4, 22, 3)); + assert_eq!(graph, Rect::new(60, 4, 15, 3)); + assert_eq!(bars, Rect::new(76, 4, 14, 3)); + assert_eq!(view.height, graph.height); + assert_eq!(graph.height, bars.height); + assert_eq!(view.x + view.width + 1, graph.x); + assert_eq!(graph.x + graph.width + 1, bars.x); + } + + #[test] + fn usage_control_groups_fall_back_when_too_narrow() { + assert!(usage_control_group_areas(Rect::new(0, 0, 52, 3)).is_none()); + assert!(usage_control_group_areas(Rect::new(0, 0, 53, 2)).is_none()); + } + #[test] fn token_chart_header_explains_the_slash_pair() { assert_eq!( @@ -3382,7 +3537,8 @@ mod tests { fn reset_summary_height_accounts_for_its_label() { let summary = "Resets: 3 available | earliest expires 18 Jul"; assert!(reset_summary_height(Some(summary), 24) > wrapped_line_count(summary, 24)); - assert_eq!(usage_controls_height(None, 80), 1); + assert_eq!(usage_controls_height(None, 80), 3); + assert_eq!(activity_controls_height(None, 80), 1); } #[test] From 732d00a261a09450dd5c02ce492002fd8c575718 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:30:02 +0300 Subject: [PATCH 4/4] feat: add clickable screen tabs --- CHANGELOG.md | 2 + README.md | 2 +- src/app/mod.rs | 26 ++++ src/ui/mod.rs | 346 ++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 297 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f852a00..47f8654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project are documented in this file. - Added compact `K/M/B/T` notation for large dashboard token values in System Compact mode, including values inside vertical chart bars. System Full keeps those values expanded, while Classic output remains unchanged. - Added mouse controls for selecting the visible statistic, chart timeframe, and chart orientation; clicking the footer cycles the display style on every screen. - Grouped the Usage screen's View, Graph, and Bars controls into aligned thin-border panels. +- Removed duplicate shortcut hints from screen headers, leaving the complete shortcut reference in the footer and help overlay. +- Added clickable screen tabs to the outer title and matching framed View/Projects controls to the Activity screen. ## 0.3.6 - 2026-06-05 diff --git a/README.md b/README.md index a79a80a..4fceb41 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ comon --scan-time-budget-ms 1500 --max-jsonl-line-kib 512 - `w` Toggle timeframe (Week/Month) - `f` Toggle layout (Horz/Vert) - `n` Cycle display formatting (Classic/System Compact/System Full) -- Mouse: click Tokens/Time/Runs, Week/Month, or Horz/Vert controls; click the footer to cycle display formatting +- Mouse: click the top tabs to switch screens; click Tokens/Time/Runs, Week/Month, Horz/Vert, or Activity project-count controls; click the footer to cycle display formatting - `s` / `F2` Switch between Usage and Session history - `r` / `F5` Refresh current screen - `?` Help overlay diff --git a/src/app/mod.rs b/src/app/mod.rs index c36fefb..a4facb7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -73,9 +73,12 @@ pub(crate) enum ActiveScreen { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum UiClickAction { + SetScreen(ActiveScreen), SetMetric(UsageMetric), SetRange(ChartRange), SetOrientation(ChartOrientation), + DecreaseProjects, + IncreaseProjects, CycleDisplayStyle, } @@ -685,6 +688,11 @@ fn ui_click_action_at(targets: &[UiHitTarget], column: u16, row: u16) -> Option< fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { match action { + UiClickAction::SetScreen(screen) => { + let changed = state.active_screen != screen; + state.active_screen = screen; + changed + } UiClickAction::SetMetric(metric) => { let changed = state.metric != metric; state.metric = metric; @@ -700,6 +708,24 @@ fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { state.orientation = orientation; changed } + UiClickAction::DecreaseProjects => { + let next = state + .activity_project_limit + .saturating_sub(1) + .max(MIN_ACTIVITY_PROJECT_LIMIT); + let changed = next != state.activity_project_limit; + state.activity_project_limit = next; + changed + } + UiClickAction::IncreaseProjects => { + let next = state + .activity_project_limit + .saturating_add(1) + .min(MAX_ACTIVITY_PROJECT_LIMIT); + let changed = next != state.activity_project_limit; + state.activity_project_limit = next; + changed + } UiClickAction::CycleDisplayStyle => { state.display_style = state.display_style.toggled(); true diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 53f8015..e5b720d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -73,20 +73,13 @@ pub fn format_updated_label(updated_at: Instant) -> String { pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { state.ui_hit_targets.clear(); let area = frame.area(); - let title = match state.active_screen { - ActiveScreen::Usage => " comon :: usage ", - ActiveScreen::Activity => " comon :: activity ", - ActiveScreen::LimitResets => " comon :: limit resets ", - ActiveScreen::Read => " comon :: session history ", - }; + let (title, navigation_targets) = navigation_title(area, state.active_screen); + state.ui_hit_targets.extend(navigation_targets); let outer = Block::default() .borders(Borders::ALL) .border_type(BorderType::Plain) - .title(Span::styled( - title, - Style::default().add_modifier(Modifier::BOLD), - )); + .title(title); frame.render_widget(outer, area); let inner = apply_margin( @@ -175,10 +168,63 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { } } +fn navigation_title(area: Rect, active_screen: ActiveScreen) -> (Line<'static>, Vec) { + let title_area = Rect::new( + area.x.saturating_add(1), + area.y, + area.width.saturating_sub(2), + 1, + ); + let segments = [ + (" comon ::", None), + ( + " USAGE ", + Some(UiClickAction::SetScreen(ActiveScreen::Usage)), + ), + ( + " ACTIVITY ", + Some(UiClickAction::SetScreen(ActiveScreen::Activity)), + ), + ( + " LIMITS ", + Some(UiClickAction::SetScreen(ActiveScreen::LimitResets)), + ), + ( + " HISTORY ", + Some(UiClickAction::SetScreen(ActiveScreen::Read)), + ), + ]; + let targets = left_aligned_targets(title_area, &segments); + if targets.len() != 4 { + let current = match active_screen { + ActiveScreen::Usage => "usage", + ActiveScreen::Activity => "activity", + ActiveScreen::LimitResets => "limit resets", + ActiveScreen::Read => "session history", + }; + return ( + Line::from(Span::styled( + format!(" comon :: {current} "), + Style::default().add_modifier(Modifier::BOLD), + )), + Vec::new(), + ); + } + + let line = Line::from(vec![ + Span::styled(" comon ::", Style::default().add_modifier(Modifier::BOLD)), + pill("USAGE", active_screen == ActiveScreen::Usage), + pill("ACTIVITY", active_screen == ActiveScreen::Activity), + pill("LIMITS", active_screen == ActiveScreen::LimitResets), + pill("HISTORY", active_screen == ActiveScreen::Read), + ]); + (line, targets) +} + fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(28)]) + .constraints([Constraint::Min(20), Constraint::Length(18)]) .split(area); let title = "USAGE_SNAPSHOT"; @@ -194,21 +240,14 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .or_else(|| state.limits_updated_label()) .unwrap_or_else(|| "Updated --".to_string()); - let right = Paragraph::new(Line::from(vec![ - Span::raw(updated), - Span::raw(" "), - Span::styled("[r/F5]", Style::default().fg(Color::Gray)), - Span::raw(" "), - Span::styled("[s/F2]", Style::default().fg(Color::Gray)), - ])) - .alignment(Alignment::Right); + let right = Paragraph::new(updated).alignment(Alignment::Right); frame.render_widget(right, row[1]); } fn render_activity_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(36)]) + .constraints([Constraint::Min(20), Constraint::Length(18)]) .split(area); let left = Paragraph::new(Line::from(Span::styled( @@ -221,21 +260,14 @@ fn render_activity_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let updated = state .usage_updated_label() .unwrap_or_else(|| "Updated --".to_string()); - let right = Paragraph::new(Line::from(vec![ - Span::raw(updated), - Span::raw(" "), - Span::styled("[+/-]", Style::default().fg(Color::Gray)), - Span::raw(" "), - Span::styled("[s/F2]", Style::default().fg(Color::Gray)), - ])) - .alignment(Alignment::Right); + let right = Paragraph::new(updated).alignment(Alignment::Right); frame.render_widget(right, row[1]); } fn render_limit_resets_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(28)]) + .constraints([Constraint::Min(20), Constraint::Length(18)]) .split(area); frame.render_widget( @@ -249,14 +281,7 @@ fn render_limit_resets_header(frame: &mut Frame<'_>, area: Rect, state: &AppStat let updated = state .limits_updated_label() .unwrap_or_else(|| "Updated --".to_string()); - let right = Paragraph::new(Line::from(vec![ - Span::raw(updated), - Span::raw(" "), - Span::styled("[r/F5]", Style::default().fg(Color::Gray)), - Span::raw(" "), - Span::styled("[s/F2]", Style::default().fg(Color::Gray)), - ])) - .alignment(Alignment::Right); + let right = Paragraph::new(updated).alignment(Alignment::Right); frame.render_widget(right, row[1]); } @@ -377,32 +402,122 @@ fn render_activity_controls( let reset_height = reset_summary_height(reset_summary, area.width); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(reset_height)]) + .constraints([Constraint::Length(3), Constraint::Length(reset_height)]) .split(area); - let row = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Min(0)]) - .split(chunks[0]); let workspace_label = state .workspace_path .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "All workspaces".to_string()); - let left = Paragraph::new(Line::from(vec![ - Span::styled("WORKSPACE", Style::default().fg(Color::Gray)), - Span::raw(" "), - Span::styled( - truncate_middle(&workspace_label, 48), - Style::default().add_modifier(Modifier::BOLD), - ), - ])); - frame.render_widget(left, row[0]); let tokens = pill("TOKENS", state.metric == UsageMetric::Tokens); let time = pill("TIME", state.metric == UsageMetric::Time); let runs = pill("RUNS", state.metric == UsageMetric::Runs); - let project_limit = state.activity_project_limit.to_string(); + let project_count = format!(" {:>2} ", state.activity_project_limit); + let project_count_span = Span::styled( + project_count.clone(), + Style::default().add_modifier(Modifier::BOLD), + ); + + if let Some([view_area, projects_area]) = activity_control_group_areas(chunks[0]) { + let workspace_area = Rect { + x: chunks[0].x, + y: chunks[0].y.saturating_add(1), + width: view_area.x.saturating_sub(chunks[0].x).saturating_sub(1), + height: 1, + }; + render_workspace_label(frame, workspace_area, &workspace_label); + render_control_group(frame, view_area, "VIEW", vec![tokens, time, runs]); + render_control_group( + frame, + projects_area, + "PROJECTS", + vec![pill("-", false), project_count_span, pill("+", false)], + ); + + register_group_targets( + state, + view_area, + &[ + ( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + ), + (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), + (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), + ], + ); + register_group_targets( + state, + projects_area, + &[ + (" - ", Some(UiClickAction::DecreaseProjects)), + (&project_count, None), + (" + ", Some(UiClickAction::IncreaseProjects)), + ], + ); + } else { + render_flat_activity_controls( + frame, + chunks[0], + state, + &workspace_label, + vec![tokens, time, runs], + &project_count, + project_count_span, + ); + } + + if let Some(text) = reset_summary { + render_reset_summary(frame, chunks[1], text); + } +} + +fn activity_control_group_areas(area: Rect) -> Option<[Rect; 2]> { + const VIEW_WIDTH: u16 = 22; + const PROJECTS_WIDTH: u16 = 12; + const GAP: u16 = 1; + const TOTAL_WIDTH: u16 = VIEW_WIDTH + PROJECTS_WIDTH + GAP; + const MIN_WORKSPACE_WIDTH: u16 = 20; + const WORKSPACE_GAP: u16 = 1; + + if area.width + < TOTAL_WIDTH + .saturating_add(MIN_WORKSPACE_WIDTH) + .saturating_add(WORKSPACE_GAP) + || area.height < 3 + { + return None; + } + + let start_x = area.x.saturating_add(area.width - TOTAL_WIDTH); + let view = Rect::new(start_x, area.y, VIEW_WIDTH, 3); + let projects = Rect::new( + view.x.saturating_add(VIEW_WIDTH + GAP), + area.y, + PROJECTS_WIDTH, + 3, + ); + Some([view, projects]) +} + +fn render_flat_activity_controls( + frame: &mut Frame<'_>, + area: Rect, + state: &mut AppState, + workspace_label: &str, + metric_pills: Vec>, + project_count: &str, + project_count_span: Span<'static>, +) { + let center = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); + let row = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Min(0)]) + .split(center); + render_workspace_label(frame, row[0], workspace_label); + register_right_aligned_targets( state, row[1], @@ -415,29 +530,29 @@ fn render_activity_controls( (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), (" PROJECTS ", None), - (&project_limit, None), + (" - ", Some(UiClickAction::DecreaseProjects)), + (project_count, None), + (" + ", Some(UiClickAction::IncreaseProjects)), ], ); - let right = Paragraph::new(Line::from(vec![ + + let mut spans = vec![ Span::styled("VIEW", Style::default().fg(Color::Gray)), Span::raw(" "), - tokens, - time, - runs, + ]; + spans.extend(metric_pills); + spans.extend([ Span::raw(" "), Span::styled("PROJECTS", Style::default().fg(Color::Gray)), Span::raw(" "), - Span::styled( - state.activity_project_limit.to_string(), - Style::default().add_modifier(Modifier::BOLD), - ), - ])) - .alignment(Alignment::Right); - frame.render_widget(right, row[1]); - - if let Some(text) = reset_summary { - render_reset_summary(frame, chunks[1], text); - } + pill("-", false), + project_count_span, + pill("+", false), + ]); + frame.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Right), + row[1], + ); } fn render_activity_heatmaps(frame: &mut Frame<'_>, area: Rect, state: &AppState) { @@ -974,8 +1089,15 @@ fn usage_control_group_areas(area: Rect) -> Option<[Rect; 3]> { const BARS_WIDTH: u16 = 14; const GAP: u16 = 1; const TOTAL_WIDTH: u16 = VIEW_WIDTH + GRAPH_WIDTH + BARS_WIDTH + GAP * 2; - - if area.width < TOTAL_WIDTH || area.height < 3 { + const MIN_WORKSPACE_WIDTH: u16 = 20; + const WORKSPACE_GAP: u16 = 1; + + if area.width + < TOTAL_WIDTH + .saturating_add(MIN_WORKSPACE_WIDTH) + .saturating_add(WORKSPACE_GAP) + || area.height < 3 + { return None; } @@ -2505,7 +2627,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" w - toggle timeframe (Week/Month)"), Line::from(" f - toggle layout (Horz/Vert)"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click controls; click footer to cycle style"), + Line::from(" Mouse - click tabs/controls; footer cycles style"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), @@ -2517,7 +2639,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" +/= - show more projects"), Line::from(" - - show fewer projects"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click view; click footer to cycle style"), + Line::from(" Mouse - click tabs/view/projects; footer cycles style"), Line::from(" r/F5 - refresh usage + limits"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), @@ -2527,7 +2649,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from("Keys:"), Line::from(" r/F5 - refresh reset credits"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click footer to cycle style"), + Line::from(" Mouse - click tabs; footer cycles style"), Line::from(" s/F2 - switch screen"), Line::from(" ? - toggle help"), Line::from(" q/Esc - quit"), @@ -2535,7 +2657,7 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) ActiveScreen::Read => Text::from(vec![ Line::from("Keys:"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), - Line::from(" Mouse - click footer to cycle style"), + Line::from(" Mouse - click tabs; footer cycles style"), Line::from(" q/Esc - quit"), ]), }; @@ -2662,6 +2784,34 @@ fn right_aligned_targets( targets } +fn left_aligned_targets( + area: Rect, + segments: &[(&str, Option)], +) -> Vec { + let total_width = segments.iter().fold(0_u16, |total, (text, _)| { + total.saturating_add(UnicodeWidthStr::width(*text).min(u16::MAX as usize) as u16) + }); + if total_width > area.width || area.height == 0 { + return Vec::new(); + } + + let mut targets = Vec::new(); + let mut x = area.x; + for (text, action) in segments { + let width = UnicodeWidthStr::width(*text).min(u16::MAX as usize) as u16; + if let Some(action) = action { + if width > 0 { + targets.push(UiHitTarget { + area: Rect::new(x, area.y, width, 1), + action: *action, + }); + } + } + x = x.saturating_add(width); + } + targets +} + fn card( title: &str, value: &str, @@ -2805,7 +2955,7 @@ fn usage_controls_height(summary: Option<&str>, width: u16) -> u16 { } fn activity_controls_height(summary: Option<&str>, width: u16) -> u16 { - 1_u16.saturating_add(reset_summary_height(summary, width)) + 3_u16.saturating_add(reset_summary_height(summary, width)) } fn render_reset_summary(frame: &mut Frame<'_>, area: Rect, text: &str) { @@ -3405,6 +3555,45 @@ mod tests { assert!(targets.is_empty()); } + #[test] + fn navigation_tabs_are_clickable_on_the_outer_border() { + let (title, targets) = navigation_title(Rect::new(4, 2, 45, 20), ActiveScreen::Activity); + + assert_eq!(title.width(), 43); + assert_eq!(targets.len(), 4); + assert_eq!(targets[0].area, Rect::new(14, 2, 7, 1)); + assert_eq!( + targets[0].action, + UiClickAction::SetScreen(ActiveScreen::Usage) + ); + assert_eq!(targets[1].area, Rect::new(21, 2, 10, 1)); + assert_eq!(targets[2].area, Rect::new(31, 2, 8, 1)); + assert_eq!(targets[3].area, Rect::new(39, 2, 9, 1)); + } + + #[test] + fn navigation_title_falls_back_before_tabs_would_touch_the_border() { + let (_, targets) = navigation_title(Rect::new(0, 0, 44, 10), ActiveScreen::Usage); + assert!(targets.is_empty()); + } + + #[test] + fn activity_control_groups_match_usage_control_height() { + let [view, projects] = + activity_control_group_areas(Rect::new(10, 4, 60, 3)).expect("framed controls"); + + assert_eq!(view, Rect::new(35, 4, 22, 3)); + assert_eq!(projects, Rect::new(58, 4, 12, 3)); + assert_eq!(view.height, projects.height); + assert_eq!(view.x + view.width + 1, projects.x); + } + + #[test] + fn activity_control_groups_preserve_workspace_width() { + assert!(activity_control_group_areas(Rect::new(0, 0, 55, 3)).is_none()); + assert!(activity_control_group_areas(Rect::new(0, 0, 56, 3)).is_some()); + } + #[test] fn usage_control_groups_are_equal_height_and_right_aligned() { let [view, graph, bars] = @@ -3421,8 +3610,9 @@ mod tests { #[test] fn usage_control_groups_fall_back_when_too_narrow() { - assert!(usage_control_group_areas(Rect::new(0, 0, 52, 3)).is_none()); - assert!(usage_control_group_areas(Rect::new(0, 0, 53, 2)).is_none()); + assert!(usage_control_group_areas(Rect::new(0, 0, 73, 3)).is_none()); + assert!(usage_control_group_areas(Rect::new(0, 0, 74, 3)).is_some()); + assert!(usage_control_group_areas(Rect::new(0, 0, 74, 2)).is_none()); } #[test] @@ -3538,7 +3728,7 @@ mod tests { let summary = "Resets: 3 available | earliest expires 18 Jul"; assert!(reset_summary_height(Some(summary), 24) > wrapped_line_count(summary, 24)); assert_eq!(usage_controls_height(None, 80), 3); - assert_eq!(activity_controls_height(None, 80), 1); + assert_eq!(activity_controls_height(None, 80), 3); } #[test]