From c9bc517931171e2536e0f869e6f0269ba90457b1 Mon Sep 17 00:00:00 2001 From: fix2it Date: Sun, 18 Oct 2026 22:34:45 +0300 Subject: [PATCH 1/6] fix(theme): round short reset durations instead of truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duration_short truncated to whole units, so 4d23h read "4d" and 59m40s could not be told from 59m. It now rounds: half steps below 10 h and 10 d (1.5ч, 2.5д), whole units above, minutes clamped to 59 so the next step is "1h", and 23h45m rolls over to "1d". Co-Authored-By: Claude Opus 5.5 --- src/theme_engine/tests.rs | 57 +++++++++++++++++++++++++- src/theme_engine/theme_expression.rs | 60 +++++++++++++++++++++------- 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index f97ba5a1..14df2b7c 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -485,9 +485,64 @@ fn templates_apply_numeric_character_formats() { format_template("Used {claude.session.percentage:0.0}%", &context), "Used 73.5%" ); - assert_eq!(format_template("{reset:duration_short}", &context), "4d"); + assert_eq!(format_template("{reset:duration_short}", &context), "5d"); } +#[test] +fn duration_short_precision_scale_and_half_steps() { + let mut context = DataContext::default(); + context.insert_string("i18n.day_suffix", "д"); + context.insert_string("i18n.hour_suffix", "ч"); + context.insert_string("i18n.minute_suffix", "м"); + context.insert_string("i18n.second_suffix", "с"); + context.insert_string("i18n.now", "сейчас"); + + let check = |secs: f64, expected: &str, ctx: &DataContext| { + let mut c = ctx.clone(); + c.insert("val", secs); + assert_eq!(format_template("{val:duration_short}", &c), expected); + }; + + // Minutes under 1 hour (< 3600s) + check(3580.0, "59м", &context); // 59m 40s must clamp to 59m, never 60m + check(3300.0, "55м", &context); + check(2700.0, "45м", &context); + check(900.0, "15м", &context); + check(60.0, "1м", &context); + + // Hours under 10 hours (1h .. 10h): 0.5 step rounding with dot + check(3600.0, "1ч", &context); + check(3900.0, "1ч", &context); // 1h 05m -> 1ч + check(5400.0, "1.5ч", &context); // 1h 30m -> 1.5ч + check(7800.0, "2ч", &context); // 2h 10m -> 2ч + check(9300.0, "2.5ч", &context); // 2h 35m -> 2.5ч + check(10500.0, "3ч", &context); // 2h 55m -> 3ч + check(34200.0, "9.5ч", &context); // 9h 30m -> 9.5ч + + // Hours >= 10 hours: integer math rounding + check(36000.0, "10ч", &context); + check(43200.0, "12ч", &context); + check(66300.0, "18ч", &context); // 18h 25m -> 18ч + check(85500.0, "1д", &context); // 23h 45m -> rolls over to 1д + + // Days < 10 days: 0.5 step rounding with dot + check(86400.0, "1д", &context); + check(129600.0, "1.5д", &context); // 1d 12h -> 1.5д + check(216000.0, "2.5д", &context); // 2d 12h -> 2.5д + check(532800.0, "6д", &context); // 6d 4h -> strictly 6д + check(565200.0, "6.5д", &context); // 6d 13h -> 6.5д + check(604800.0, "7д", &context); // 7d -> 7д + + // Days >= 10 days: integer math rounding + check(892800.0, "10д", &context); // 10d 8h -> 10д + check(1036800.0, "12д", &context); // 12d -> 12д + + // Seconds and now + check(45.0, "45с", &context); + check(0.0, "сейчас", &context); +} + + #[test] fn text_templates_allow_if_to_return_quoted_strings() { let mut context = DataContext::default(); diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index 62bf59ca..1d0ff10d 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -582,26 +582,58 @@ pub(super) fn localized<'a>(context: &'a DataContext, name: &str, fallback: &'a context.get_string(name).unwrap_or(fallback) } +fn format_half_step(val: f64, suffix: &str) -> String { + let rounded = (val * 2.0).round() / 2.0; + if rounded.fract().abs() < 1e-4 { + format!("{:.0}{suffix}", rounded) + } else { + format!("{:.1}{suffix}", rounded) + } +} + +pub(super) fn format_duration_short(seconds: u64, context: &DataContext) -> String { + let day_suffix = localized(context, "i18n.day_suffix", "d"); + let hour_suffix = localized(context, "i18n.hour_suffix", "h"); + let minute_suffix = localized(context, "i18n.minute_suffix", "m"); + let second_suffix = localized(context, "i18n.second_suffix", "s"); + + if seconds >= 86_400 { + if seconds >= 864_000 { + let days = (seconds + 43_200) / 86_400; + format!("{days}{day_suffix}") + } else { + let days = seconds as f64 / 86_400.0; + format_half_step(days, day_suffix) + } + } else if seconds >= 3_600 { + if seconds >= 36_000 { + let hours = (seconds + 1800) / 3600; + if hours >= 24 { + format!("1{day_suffix}") + } else { + format!("{hours}{hour_suffix}") + } + } else { + let hours = seconds as f64 / 3600.0; + format_half_step(hours, hour_suffix) + } + } else if seconds >= 60 { + let minutes = ((seconds + 30) / 60).min(59); + format!("{minutes}{minute_suffix}") + } else if seconds > 0 { + format!("{seconds}{second_suffix}") + } else { + localized(context, "i18n.now", "now").to_string() + } +} + pub(super) fn format_value(value: f64, format: &str, context: &DataContext) -> String { if let Some(value) = format_timestamp(value, format, context) { return value; } if format.eq_ignore_ascii_case("duration_short") { let seconds = value.max(0.0).round() as u64; - let days = seconds / 86_400; - let hours = seconds / 3_600; - let minutes = seconds / 60; - return if days > 0 { - format!("{days}{}", localized(context, "i18n.day_suffix", "d")) - } else if hours > 0 { - format!("{hours}{}", localized(context, "i18n.hour_suffix", "h")) - } else if minutes > 0 { - format!("{minutes}{}", localized(context, "i18n.minute_suffix", "m")) - } else if seconds > 0 { - format!("{seconds}{}", localized(context, "i18n.second_suffix", "s")) - } else { - localized(context, "i18n.now", "now").to_string() - }; + return format_duration_short(seconds, context); } if format.eq_ignore_ascii_case("duration") { let seconds = value.max(0.0).round() as u64; From 144576b09a245ab9fedd8622c0fa5af0c2ec528e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:29:58 -0500 Subject: [PATCH 2/6] chore(deps): bump serde_json from 1.0.151 to 1.0.154 (#6) Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.151 to 1.0.154. - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154) --- updated-dependencies: - dependency-name: serde_json dependency-version: 1.0.154 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c203c717..55882753 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2196,9 +2196,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", From 4207f1819fb1089f99b2bc5820e2d719b6f75eb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:30:10 -0500 Subject: [PATCH 3/6] chore(deps): bump lucide-icons from 1.52.0 to 1.55.0 (#4) Bumps [lucide-icons](https://github.com/WhySoBad/lucide-icons-rs) from 1.52.0 to 1.55.0. - [Commits](https://github.com/WhySoBad/lucide-icons-rs/commits) --- updated-dependencies: - dependency-name: lucide-icons dependency-version: 1.55.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55882753..8f31f7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,9 +1306,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lucide-icons" -version = "1.52.0" +version = "1.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "600e9f65483f450fef5d8e5ea075f55ab891ccfcd0671d5e4dd60dd18c0d5a2b" +checksum = "15477dff3b89bd25ddbbd9b6855fb1c7055558a296f1d2282a6294b2b37ab458" [[package]] name = "memchr" From 10a384823fa06c4ef2e5017634afba41e3ce9ad2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:30:26 -0500 Subject: [PATCH 4/6] chore(deps): bump winresource from 0.1.31 to 0.1.34 (#7) Bumps [winresource](https://github.com/BenjaminRi/winresource) from 0.1.31 to 0.1.34. - [Commits](https://github.com/BenjaminRi/winresource/commits) --- updated-dependencies: - dependency-name: winresource dependency-version: 0.1.34 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f31f7c5..b22360f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3216,9 +3216,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", From 9f76b30fe7988be437382d8d517f316c9d2328c0 Mon Sep 17 00:00:00 2001 From: fix2it Date: Mon, 19 Oct 2026 02:07:49 +0300 Subject: [PATCH 5/6] feat(compact): pace tick on usage gauges with a menu toggle Draw a 1 px tick on each Claude, Codex and Antigravity 5-hour and weekly gauge in the compact theme at the share of the window that has elapsed (remaining mode: 100 - elapsed). The engine exposes {window}.expected / expected_display / expected_available. Settings menu gets "Show pace" (on by default, display.show_pace binding). Co-Authored-By: Claude Opus 5.5 --- src/app_settings.rs | 24 + src/context_menu.rs | 16 + src/localization/locales/de.toml | 1 + src/localization/locales/en.toml | 1 + src/localization/locales/es.toml | 1 + src/localization/locales/fr.toml | 1 + src/localization/locales/ja.toml | 1 + src/localization/locales/ko.toml | 1 + src/localization/locales/nl.toml | 1 + src/localization/locales/pl.toml | 1 + src/localization/locales/pt-BR.toml | 1 + src/localization/locales/ru.toml | 1 + src/localization/locales/th.toml | 1 + src/localization/locales/tr.toml | 1 + src/localization/locales/zh-CN.toml | 1 + src/localization/locales/zh-TW.toml | 1 + src/studio_app/studio_context_menu_editor.rs | 2 + src/studio_app/studio_core.rs | 1 + src/studio_app/studio_helper.rs | 8 + src/studio_app/studio_native_menu.rs | 1 + src/studio_app/tests.rs | 1 + src/theme_engine.rs | 42 ++ src/theme_engine/tests.rs | 212 +++++++ src/themes/compact-fluent-quad.json | 624 +++++++++++++++++++ src/window.rs | 9 +- src/window/message_loop.rs | 11 + src/window/placement_regression_tests.rs | 1 + src/window/window_context_menu.rs | 2 + 28 files changed, 967 insertions(+), 1 deletion(-) diff --git a/src/app_settings.rs b/src/app_settings.rs index 4ddbb981..97019ebb 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -72,6 +72,8 @@ pub struct SettingsFile { /// the widget counts down towards a limit rather than up from zero. #[serde(default)] pub usage_countdown: bool, + #[serde(default = "default_true")] + pub show_pace: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_theme_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -139,6 +141,7 @@ impl Default for SettingsFile { show_copilot: false, custom_theme_enabled: true, usage_countdown: false, + show_pace: true, active_theme_path: None, dashboard_width: None, dashboard_height: None, @@ -660,6 +663,27 @@ mod tests { assert_eq!(settings_json(&counting_down)["usage_countdown"], true); } + #[test] + fn show_pace_defaults_to_on_and_round_trips() { + let settings = SettingsFile::default(); + assert!(settings.show_pace); + assert_eq!(settings_json(&settings)["show_pace"], true); + + let legacy = decode_settings(r#"{"poll_interval_ms":900000}"#).unwrap(); + assert!(legacy.show_pace); + + for show_pace in [false, true] { + let settings = SettingsFile { + show_pace, + ..Default::default() + }; + let json = settings_json(&settings); + assert_eq!(json["show_pace"], show_pace); + let loaded = decode_settings(&json.to_string()).unwrap(); + assert_eq!(loaded.show_pace, show_pace); + } + } + #[test] fn settings_always_use_the_theme_widget() { let mut settings = SettingsFile { diff --git a/src/context_menu.rs b/src/context_menu.rs index bf87f113..ee2e65a8 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -68,6 +68,7 @@ pub enum ContextMenuAction { ToggleAntigravityExternal, ToggleStartup, ToggleTaskbarLock, + ToggleShowPace, ToggleWidget, /// Accepted only so menus saved by older versions can be loaded and /// cleaned up. New menus cannot create or execute this legacy action. @@ -325,6 +326,7 @@ pub fn rendered_label( "Check for updates" => language.text("Check for updates"), "Lock in taskbar" => language.text("Lock in taskbar"), "Show widget" => language.text("Show widget"), + "Show pace" => language.text("Show pace"), _ => label, }; crate::theme_engine::format_template(translated, context) @@ -457,6 +459,7 @@ pub fn classic_context_menu() -> ContextMenuDocument { Action::ToggleStartup, ), ContextMenuItem::action("lock-taskbar", "Lock in taskbar", Action::ToggleTaskbarLock), + ContextMenuItem::action("show-pace", "Show pace", Action::ToggleShowPace), ContextMenuItem::submenu("language", "Language", languages), ContextMenuItem::separator("settings-separator"), ContextMenuItem::action( @@ -718,6 +721,19 @@ mod tests { assert!(menu.validate().is_empty()); assert!(menu.items.iter().any(|item| item.id == "refresh")); assert!(menu.items.iter().any(|item| item.id == "update-frequency")); + let settings = menu.items.iter().find(|item| item.id == "settings").unwrap(); + let ContextMenuItemKind::Submenu { items } = &settings.kind else { + panic!("Settings must be a submenu"); + }; + assert!(items.iter().any(|item| { + item.label == "Show pace" + && matches!( + &item.kind, + ContextMenuItemKind::Action { + action: ContextMenuAction::ToggleShowPace + } + ) + })); assert!(serde_json::to_string(&menu) .unwrap() .contains("provider-opencode")); diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index df432b1e..9cc0fc10 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -360,6 +360,7 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Short duration" = "Kurze Dauer" "Show context menu" = "Kontextmenü anzeigen" "Show dashboard" = "Dashboard anzeigen" +"Show pace" = "Show pace" "Show widget" = "Widget anzeigen" "Size" = "Größe" "Smaller value" = "Kleinerer Wert" diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index 2273418e..64be92a1 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -360,6 +360,7 @@ copilot_window_title = "Copilot Usage Monitor" "Short duration" = "Short duration" "Show context menu" = "Show context menu" "Show dashboard" = "Show dashboard" +"Show pace" = "Show pace" "Show widget" = "Show widget" "Size" = "Size" "Smaller value" = "Smaller value" diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 41ac2ed6..1047088b 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -360,6 +360,7 @@ copilot_window_title = "Monitor de uso de Copilot" "Short duration" = "Duración corta" "Show context menu" = "Mostrar menú contextual" "Show dashboard" = "Mostrar panel" +"Show pace" = "Show pace" "Show widget" = "Mostrar widget" "Size" = "Tamaño" "Smaller value" = "Valor menor" diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index 34545dcf..d2de06c7 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -360,6 +360,7 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Short duration" = "Durée courte" "Show context menu" = "Afficher le menu contextuel" "Show dashboard" = "Afficher le tableau de bord" +"Show pace" = "Show pace" "Show widget" = "Afficher le widget" "Size" = "Taille" "Smaller value" = "Valeur la plus petite" diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 6d6de298..5801a1fa 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -362,6 +362,7 @@ copilot_window_title = "Copilot 使用量モニター" "Short duration" = "短い期間" "Show context menu" = "コンテキストメニューを表示" "Show dashboard" = "ダッシュボードを表示" +"Show pace" = "Show pace" "Show widget" = "ウィジェットを表示" "Size" = "サイズ" "Smaller value" = "小さい値" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index 6d73478c..48db5e64 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -362,6 +362,7 @@ copilot_window_title = "Copilot 사용량 모니터" "Short duration" = "간단한 기간" "Show context menu" = "컨텍스트 메뉴 표시" "Show dashboard" = "대시보드 표시" +"Show pace" = "Show pace" "Show widget" = "위젯 표시" "Size" = "크기" "Smaller value" = "더 작은 값" diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index 50550807..bb5c84b6 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -360,6 +360,7 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Short duration" = "Korte duur" "Show context menu" = "Contextmenu tonen" "Show dashboard" = "Dashboard tonen" +"Show pace" = "Show pace" "Show widget" = "Widget tonen" "Size" = "Grootte" "Smaller value" = "Kleinste waarde" diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index c743ce77..01bfe530 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -360,6 +360,7 @@ copilot_window_title = "Monitor zużycia Copilot" "Short duration" = "Skrócony czas trwania" "Show context menu" = "Pokaż menu kontekstowe" "Show dashboard" = "Pokaż panel" +"Show pace" = "Show pace" "Show widget" = "Pokaż widżet" "Size" = "Rozmiar" "Smaller value" = "Mniejsza z wartości" diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index 7719834b..e571815e 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -360,6 +360,7 @@ copilot_window_title = "Monitor de uso do Copilot" "Short duration" = "Duração curta" "Show context menu" = "Mostrar menu de contexto" "Show dashboard" = "Mostrar painel" +"Show pace" = "Show pace" "Show widget" = "Mostrar widget" "Size" = "Tamanho" "Smaller value" = "Menor valor" diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index 19d8aa49..58a6a5da 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -360,6 +360,7 @@ copilot_window_title = "Монитор использования Copilot" "Short duration" = "Краткая длительность" "Show context menu" = "Показать контекстное меню" "Show dashboard" = "Показать панель" +"Show pace" = "Показывать темп" "Show widget" = "Показать виджет" "Size" = "Размер" "Smaller value" = "Меньшее значение" diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index c2a936ee..27f9cef7 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -362,6 +362,7 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Short duration" = "ระยะเวลาแบบสั้น" "Show context menu" = "แสดงเมนูคลิกขวา" "Show dashboard" = "แสดงแดชบอร์ด" +"Show pace" = "Show pace" "Show widget" = "แสดงวิดเจ็ต" "Size" = "ขนาด" "Smaller value" = "ค่าที่น้อยกว่า" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index cf8706c8..17f96e21 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -360,6 +360,7 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Short duration" = "Kısa süre" "Show context menu" = "Bağlam menüsünü göster" "Show dashboard" = "Panoyu göster" +"Show pace" = "Show pace" "Show widget" = "Pencere öğesini göster" "Size" = "Boyut" "Smaller value" = "Küçük değer" diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index 82c94804..5a918fba 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -362,6 +362,7 @@ copilot_window_title = "Copilot 使用量监控" "Short duration" = "简短时长" "Show context menu" = "显示上下文菜单" "Show dashboard" = "显示仪表板" +"Show pace" = "Show pace" "Show widget" = "显示小组件" "Size" = "大小" "Smaller value" = "较小的值" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index b0644822..f73c2474 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -362,6 +362,7 @@ copilot_window_title = "Copilot 使用量監控" "Short duration" = "簡短時間" "Show context menu" = "顯示快顯功能表" "Show dashboard" = "顯示儀表板" +"Show pace" = "Show pace" "Show widget" = "顯示小工具" "Size" = "大小" "Smaller value" = "較小的值" diff --git a/src/studio_app/studio_context_menu_editor.rs b/src/studio_app/studio_context_menu_editor.rs index a6df389e..e427e4b7 100644 --- a/src/studio_app/studio_context_menu_editor.rs +++ b/src/studio_app/studio_context_menu_editor.rs @@ -325,6 +325,7 @@ pub(super) fn context_menu_action_script(action: &ContextMenuAction) -> String { ContextMenuAction::ToggleAntigravityExternal => "toggle_antigravity_external()".into(), ContextMenuAction::ToggleStartup => "toggle_startup()".into(), ContextMenuAction::ToggleTaskbarLock => "toggle_taskbar_lock()".into(), + ContextMenuAction::ToggleShowPace => "toggle_show_pace()".into(), ContextMenuAction::ToggleWidget => "toggle_widget()".into(), ContextMenuAction::LegacyResetPosition => String::new(), ContextMenuAction::SetLanguage { language } => { @@ -352,6 +353,7 @@ pub(super) fn parse_context_menu_action_script(script: &str) -> Result ContextMenuAction::ToggleWidget, "toggle_startup" => ContextMenuAction::ToggleStartup, "toggle_taskbar_lock" => ContextMenuAction::ToggleTaskbarLock, + "toggle_show_pace" => ContextMenuAction::ToggleShowPace, "check_for_updates" => ContextMenuAction::CheckForUpdates, "exit" => ContextMenuAction::Exit, "set_update_frequency" => { diff --git a/src/studio_app/studio_native_menu.rs b/src/studio_app/studio_native_menu.rs index 663ae780..9646537a 100644 --- a/src/studio_app/studio_native_menu.rs +++ b/src/studio_app/studio_native_menu.rs @@ -575,6 +575,7 @@ pub(super) fn context_menu_preview_action_checked( ContextMenuAction::ToggleProvider { provider } => settings.provider_enabled(*provider), ContextMenuAction::ToggleStartup => startup_enabled, ContextMenuAction::ToggleTaskbarLock => settings.lock_taskbar, + ContextMenuAction::ToggleShowPace => settings.show_pace, ContextMenuAction::ToggleWidget => theme .surfaces .iter() diff --git a/src/studio_app/tests.rs b/src/studio_app/tests.rs index 0919d912..9ef8ee1d 100644 --- a/src/studio_app/tests.rs +++ b/src/studio_app/tests.rs @@ -793,6 +793,7 @@ fn context_menu_action_scripts_round_trip_every_action_kind() { }, ContextMenuAction::ToggleStartup, ContextMenuAction::ToggleTaskbarLock, + ContextMenuAction::ToggleShowPace, ContextMenuAction::ToggleWidget, ContextMenuAction::SetLanguage { language: "en-US".into(), diff --git a/src/theme_engine.rs b/src/theme_engine.rs index 61e029ec..6df9ee96 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1334,6 +1334,7 @@ pub struct ThemeRuntime { /// the `.display` values and summaries using them follow this; `.percentage` /// always means consumption so severity thresholds keep their meaning. pub countdown: bool, + pub show_pace: bool, pub surface_nest: SurfaceNest, pub floating_card_opacity: u8, pub antigravity_gemini_enabled: bool, @@ -1351,6 +1352,7 @@ impl Default for ThemeRuntime { has_error: false, language: LanguageId::English, countdown: false, + show_pace: true, surface_nest: SurfaceNest::Taskbar, floating_card_opacity: 85, antigravity_gemini_enabled: true, @@ -1385,6 +1387,7 @@ impl ThemeRuntime { has_error: false, language: LanguageId::English, countdown: false, + show_pace: true, surface_nest: SurfaceNest::Taskbar, floating_card_opacity: 85, antigravity_gemini_enabled: agy, @@ -1434,6 +1437,11 @@ impl ThemeRuntime { self } + pub fn with_show_pace(mut self, show_pace: bool) -> Self { + self.show_pace = show_pace; + self + } + /// Supply the selected native host's 96-DPI logical dimensions. Theme /// expressions consume these as `host.width` and `host.height`. pub fn with_host_dimensions(mut self, width: u32, height: u32) -> Self { @@ -1455,6 +1463,19 @@ impl ThemeRuntime { } } +const FIVE_HOURS: std::time::Duration = std::time::Duration::from_secs(5 * 3600); +const WEEK: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600); + +/// Percent of a window of `length` that has passed by now, given when it +/// resets; `None` when the reset is not within one window from now. +fn expected_used(resets_at: std::time::SystemTime, length: std::time::Duration) -> Option { + let left = resets_at + .duration_since(std::time::SystemTime::now()) + .ok()?; + (!length.is_zero() && left <= length) + .then(|| (1.0 - left.as_secs_f64() / length.as_secs_f64()) * 100.0) +} + impl DataContext { pub fn from_usage(data: Option<&AppUsageData>, canvas: &Canvas) -> Self { Self::from_usage_with_runtime(data, canvas, ThemeRuntime::default()) @@ -1531,6 +1552,7 @@ impl DataContext { ); } context.insert("display.countdown", runtime.countdown as u8 as f64); + context.insert("display.show_pace", runtime.show_pace as u8 as f64); context.insert( "antigravity.gemini.enabled", runtime.antigravity_gemini_enabled as u8 as f64, @@ -1716,6 +1738,26 @@ impl DataContext { display(weekly) }; self.insert(&format!("{name}.weekly.display"), shown_weekly); + // Pace: the share a window would have spent by now at an even rate. + // `expected_display` follows the countdown setting like `display`. + let five_hour_section = usage.filter(|usage| usage.session.available).map(|usage| &usage.session); + let weekly_section = usage.filter(|usage| usage.weekly.available).map(|usage| &usage.weekly); + let session_section = if use_codex_session_fallback { + weekly_section.map(|section| (section, WEEK)) + } else { + five_hour_section.map(|section| (section, FIVE_HOURS)) + }; + for (window, section) in [ + ("session", session_section), + ("five_hour", five_hour_section.map(|section| (section, FIVE_HOURS))), + ("weekly", weekly_section.map(|section| (section, WEEK))), + ] { + let expected = section.and_then(|(section, length)| expected_used(section.resets_at?, length)); + self.insert(&format!("{name}.{window}.expected_available"), expected.is_some() as u8 as f64); + let expected = expected.unwrap_or(0.0); + self.insert(&format!("{name}.{window}.expected"), expected); + self.insert(&format!("{name}.{window}.expected_display"), display(expected)); + } let monthly = usage.and_then(|usage| usage.monthly.as_ref()); self.insert_string(&format!("{name}.monthly.label"), "30d"); if let Some(monthly) = monthly { diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 199162f9..08a2fedb 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -3477,3 +3477,215 @@ fn missing_antigravity_external_pool_draws_an_empty_gauge() { assert_eq!(value("antigravity.external.session.display"), 0.0); } +#[test] +fn pace_expected_used_rejects_resets_outside_the_window() { + use std::time::{Duration, SystemTime}; + + let now = SystemTime::now(); + for length in [FIVE_HOURS, WEEK] { + let half = expected_used(now + length / 2, length).unwrap(); + assert!((half - 50.0).abs() < 0.1, "{half}"); + assert_eq!(expected_used(SystemTime::UNIX_EPOCH, length), None); + assert_eq!( + expected_used(now + length + Duration::from_secs(60), length), + None + ); + } + assert_eq!(expected_used(now + FIVE_HOURS, Duration::ZERO), None); +} + +#[test] +fn pace_bindings_follow_elapsed_time_and_countdown_for_each_pool() { + use crate::models::{UsageData, UsageSection}; + use std::time::{Duration, SystemTime}; + + let now = SystemTime::now(); + let section = |length: Duration, elapsed_quarters: u32| UsageSection { + available: true, + percentage: 90.0, + resets_at: Some(now + length * (4 - elapsed_quarters) / 4), + }; + for elapsed_quarters in [1, 2] { + let provider_usage = UsageData { + session: section(FIVE_HOURS, elapsed_quarters), + weekly: section(WEEK, elapsed_quarters), + external_pool: Some(Box::new(UsageData { + session: section(FIVE_HOURS, 4 - elapsed_quarters), + weekly: section(WEEK, 4 - elapsed_quarters), + ..Default::default() + })), + ..Default::default() + }; + let usage = AppUsageData::from_iter( + [ProviderId::Claude, ProviderId::Codex, ProviderId::Antigravity] + .into_iter() + .map(|provider| (provider, provider_usage.clone())), + ); + for countdown in [false, true] { + let context = DataContext::from_usage_with_runtime( + Some(&usage), + &Canvas::default(), + ThemeRuntime::default().with_countdown(countdown), + ); + for name in ["claude", "codex", "antigravity.gemini", "antigravity.external"] { + let expected = if name == "antigravity.external" { + (4 - elapsed_quarters) as f64 * 25.0 + } else { + elapsed_quarters as f64 * 25.0 + }; + for window in ["session", "five_hour", "weekly"] { + let base = format!("{name}.{window}"); + assert_eq!( + context.get(&format!("{base}.expected_available")), + Some(1.0) + ); + let used = context.get(&format!("{base}.expected")).unwrap(); + let display = context.get(&format!("{base}.expected_display")).unwrap(); + assert!((used - expected).abs() < 0.1, "{base}: {used}"); + assert!( + (display - if countdown { 100.0 - expected } else { expected }).abs() < 0.1, + "{base}: {display}" + ); + } + } + } + } +} + +#[test] +fn pace_bindings_hide_missing_windows_and_reset_times() { + use crate::models::{UsageData, UsageSection}; + use std::time::{Duration, SystemTime}; + + let now = SystemTime::now(); + for countdown in [false, true] { + for (available, resets_at) in [ + (true, None), + (false, Some(now + Duration::from_secs(60))), + (true, Some(SystemTime::UNIX_EPOCH)), + (true, Some(now + WEEK + Duration::from_secs(60))), + ] { + let section = UsageSection { + available, + percentage: 25.0, + resets_at, + }; + let usage = UsageData { + session: section.clone(), + weekly: section, + ..Default::default() + }; + let mut context = DataContext::default(); + for data in [None, Some(&usage)] { + context.insert_provider("claude", data, false, countdown); + for window in ["session", "five_hour", "weekly"] { + assert_eq!( + context.get(&format!("claude.{window}.expected_available")), + Some(0.0) + ); + assert_eq!( + context.get(&format!("claude.{window}.expected")), + Some(0.0) + ); + } + } + } + } +} + +#[test] +fn show_pace_binding_follows_the_setting() { + let defaults = crate::app_settings::SettingsFile::default(); + assert!(defaults.show_pace); + let context = DataContext::from_usage(None, &Canvas::default()); + assert_eq!(context.get("display.show_pace"), Some(1.0)); + for show_pace in [false, true] { + let mut settings = crate::app_settings::SettingsFile::default(); + settings.show_pace = show_pace; + let runtime = ThemeRuntime::from_providers(settings.enabled_providers()) + .with_show_pace(settings.show_pace); + let context = DataContext::from_usage_with_runtime(None, &Canvas::default(), runtime); + assert_eq!(context.get("display.show_pace"), Some(show_pace as u8 as f64)); + } +} + +#[test] +fn compact_pace_ticks_validate_and_stay_inside_their_gauges() { + let theme: ThemeDocument = + serde_json::from_str(include_str!("../themes/compact-fluent-quad.json")).unwrap(); + assert!(theme.validate().is_empty(), "{:?}", theme.validate()); + let children = &theme.surfaces[0].children; + for window in ["session", "weekly"] { + assert!(children + .iter() + .any(|object| object.id == format!("claude-{window}-segments-dark-pace"))); + } + let ticks = children + .iter() + .enumerate() + .filter(|(_, object)| object.id.ends_with("-pace")) + .collect::>(); + assert_eq!(ticks.len(), 24); + for (index, tick) in ticks { + let gauge = &children[index - 1]; + assert_eq!(tick.id, format!("{}-pace", gauge.id)); + assert!(matches!(&gauge.content, SceneContent::Progress { .. })); + assert!(matches!(&tick.content, SceneContent::None)); + assert_eq!(tick.parent, gauge.parent); + assert_eq!(tick.render.0, gauge.render.0); + let binding = tick + .visibility + .0 + .strip_prefix("(100) * ") + .unwrap() + .strip_suffix(".expected_available * display.show_pace") + .unwrap(); + let LayerBackground::Colour { colour } = &tick.background else { + panic!("{} must have a colour background", tick.id); + }; + assert!(parse_color(&colour.color).is_some()); + let expected_colour = if tick.id.ends_with("-dark-pace") { + "#FFFFFFFF" + } else { + "#1A1A1AFF" + }; + assert_eq!(colour.color, expected_colour); + let mut context = DataContext::from_usage(None, &Canvas::default()); + for count in 1..=ProviderId::ALL.len() { + context.insert("providers.count", count as f64); + let start = evaluate(&gauge.x.0, &context).unwrap(); + let end = start + evaluate(&gauge.width.0, &context).unwrap(); + assert_eq!(evaluate(&tick.width.0, &context).unwrap(), 1.0); + assert_eq!( + evaluate(&tick.y.0, &context).unwrap(), + evaluate(&gauge.y.0, &context).unwrap() - 1.0 + ); + assert_eq!( + evaluate(&tick.height.0, &context).unwrap(), + evaluate(&gauge.height.0, &context).unwrap() + 2.0 + ); + assert_eq!(evaluate(&colour.opacity.0, &context).unwrap(), 0.9); + for available in [0.0, 1.0] { + context.insert(&format!("{binding}.expected_available"), available); + for show_pace in [0.0, 1.0] { + context.insert("display.show_pace", show_pace); + assert_eq!( + evaluate(&tick.visibility.0, &context).unwrap(), + available * show_pace * 100.0 + ); + } + } + for expected in [-10.0, 0.0, 0.1, 25.0, 50.0, 99.9, 100.0, 110.0] { + context.insert(&format!("{binding}.expected_display"), expected); + let x = evaluate(&tick.x.0, &context).unwrap(); + assert!(x >= start && x + 1.0 <= end, "{}: {x}", tick.id); + assert_eq!(x.fract(), 0.0); + if expected <= 0.0 { + assert_eq!(x, start); + } else if expected >= 100.0 { + assert_eq!(x + 1.0, end); + } + } + } + } +} diff --git a/src/themes/compact-fluent-quad.json b/src/themes/compact-fluent-quad.json index 55250224..c0bcbf96 100644 --- a/src/themes/compact-fluent-quad.json +++ b/src/themes/compact-fluent-quad.json @@ -405,6 +405,32 @@ "segment_gap": "1" } }, + { + "id": "claude-session-segments-dark-pace", + "name": "dark Claude Code 5-hour segments pace tick", + "render": "system.dark", + "visibility": "(100) * claude.session.expected_available * display.show_pace", + "parent": "claude-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(claude.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "claude-session-value-dark", "name": "dark Claude Code 5-hour value", @@ -473,6 +499,32 @@ "segment_gap": "1" } }, + { + "id": "claude-session-segments-light-pace", + "name": "light Claude Code 5-hour segments pace tick", + "render": "1 - system.dark", + "visibility": "(100) * claude.session.expected_available * display.show_pace", + "parent": "claude-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(claude.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "claude-session-value-light", "name": "light Claude Code 5-hour value", @@ -541,6 +593,32 @@ "segment_gap": "1" } }, + { + "id": "claude-weekly-segments-dark-pace", + "name": "dark Claude Code 7-day segments pace tick", + "render": "system.dark", + "visibility": "(100) * claude.weekly.expected_available * display.show_pace", + "parent": "claude-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(claude.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "claude-weekly-value-dark", "name": "dark Claude Code 7-day value", @@ -609,6 +687,32 @@ "segment_gap": "1" } }, + { + "id": "claude-weekly-segments-light-pace", + "name": "light Claude Code 7-day segments pace tick", + "render": "1 - system.dark", + "visibility": "(100) * claude.weekly.expected_available * display.show_pace", + "parent": "claude-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(claude.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "claude-weekly-value-light", "name": "light Claude Code 7-day value", @@ -851,6 +955,32 @@ "segment_gap": "1" } }, + { + "id": "codex-session-segments-dark-pace", + "name": "dark Codex 5-hour segments pace tick", + "render": "system.dark", + "visibility": "(100) * codex.five_hour.expected_available * display.show_pace", + "parent": "codex-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(codex.five_hour.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "codex-session-value-dark", "name": "dark Codex 5-hour value", @@ -919,6 +1049,32 @@ "segment_gap": "1" } }, + { + "id": "codex-session-segments-light-pace", + "name": "light Codex 5-hour segments pace tick", + "render": "1 - system.dark", + "visibility": "(100) * codex.five_hour.expected_available * display.show_pace", + "parent": "codex-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(codex.five_hour.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "codex-session-value-light", "name": "light Codex 5-hour value", @@ -987,6 +1143,32 @@ "segment_gap": "1" } }, + { + "id": "codex-weekly-segments-dark-pace", + "name": "dark Codex 7-day segments pace tick", + "render": "system.dark", + "visibility": "(100) * codex.weekly.expected_available * display.show_pace", + "parent": "codex-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(codex.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "codex-weekly-value-dark", "name": "dark Codex 7-day value", @@ -1055,6 +1237,32 @@ "segment_gap": "1" } }, + { + "id": "codex-weekly-segments-light-pace", + "name": "light Codex 7-day segments pace tick", + "render": "1 - system.dark", + "visibility": "(100) * codex.weekly.expected_available * display.show_pace", + "parent": "codex-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(codex.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "codex-weekly-value-light", "name": "light Codex 7-day value", @@ -1143,6 +1351,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-gemini-solo-dark-pace", + "name": "dark Antigravity Gemini solo session segments pace tick", + "render": "system.dark && antigravity.gemini.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-gemini-solo-light", "name": "light Antigravity Gemini solo session segments", @@ -1178,6 +1412,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-gemini-solo-light-pace", + "name": "light Antigravity Gemini solo session segments pace tick", + "render": "(1 - system.dark) && antigravity.gemini.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-external-solo-dark", "name": "dark Antigravity External solo session segments", @@ -1213,6 +1473,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-external-solo-dark-pace", + "name": "dark Antigravity External solo session segments pace tick", + "render": "system.dark && antigravity.external.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.external.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-external-solo-light", "name": "light Antigravity External solo session segments", @@ -1248,6 +1534,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-external-solo-light-pace", + "name": "light Antigravity External solo session segments pace tick", + "render": "(1 - system.dark) && antigravity.external.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.external.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-gemini-dual-dark", "name": "dark Antigravity Gemini dual session segments", @@ -1283,6 +1595,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-gemini-dual-dark-pace", + "name": "dark Antigravity Gemini dual session segments pace tick", + "render": "system.dark && antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-gemini-dual-light", "name": "light Antigravity Gemini dual session segments", @@ -1318,6 +1656,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-gemini-dual-light-pace", + "name": "light Antigravity Gemini dual session segments pace tick", + "render": "(1 - system.dark) && antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.session.expected_display, 0, 100) / 100)", + "y": "(11) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-external-dual-dark", "name": "dark Antigravity External dual session segments", @@ -1353,6 +1717,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-external-dual-dark-pace", + "name": "dark Antigravity External dual session segments pace tick", + "render": "system.dark && antigravity.dual_mode", + "visibility": "(100) * antigravity.external.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.session.expected_display, 0, 100) / 100)", + "y": "(16) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-external-dual-light", "name": "light Antigravity External dual session segments", @@ -1388,6 +1778,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-session-external-dual-light-pace", + "name": "light Antigravity External dual session segments pace tick", + "render": "(1 - system.dark) && antigravity.dual_mode", + "visibility": "(100) * antigravity.external.session.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.session.expected_display, 0, 100) / 100)", + "y": "(16) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-session-gemini-value-dark", "name": "dark Antigravity Gemini session value", @@ -1555,6 +1971,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-gemini-solo-dark-pace", + "name": "dark Antigravity Gemini solo weekly segments pace tick", + "render": "system.dark && antigravity.gemini.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-gemini-solo-light", "name": "light Antigravity Gemini solo weekly segments", @@ -1590,6 +2032,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-gemini-solo-light-pace", + "name": "light Antigravity Gemini solo weekly segments pace tick", + "render": "(1 - system.dark) && antigravity.gemini.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-external-solo-dark", "name": "dark Antigravity External solo weekly segments", @@ -1625,6 +2093,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-external-solo-dark-pace", + "name": "dark Antigravity External solo weekly segments pace tick", + "render": "system.dark && antigravity.external.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.external.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-external-solo-light", "name": "light Antigravity External solo weekly segments", @@ -1660,6 +2154,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-external-solo-light-pace", + "name": "light Antigravity External solo weekly segments pace tick", + "render": "(1 - system.dark) && antigravity.external.enabled && !antigravity.dual_mode", + "visibility": "(100) * antigravity.external.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(9) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-gemini-dual-dark", "name": "dark Antigravity Gemini dual weekly segments", @@ -1695,6 +2215,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-gemini-dual-dark-pace", + "name": "dark Antigravity Gemini dual weekly segments pace tick", + "render": "system.dark && antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-gemini-dual-light", "name": "light Antigravity Gemini dual weekly segments", @@ -1730,6 +2276,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-gemini-dual-light-pace", + "name": "light Antigravity Gemini dual weekly segments pace tick", + "render": "(1 - system.dark) && antigravity.dual_mode", + "visibility": "(100) * antigravity.gemini.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.gemini.weekly.expected_display, 0, 100) / 100)", + "y": "(27) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-external-dual-dark", "name": "dark Antigravity External dual weekly segments", @@ -1765,6 +2337,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-external-dual-dark-pace", + "name": "dark Antigravity External dual weekly segments pace tick", + "render": "system.dark && antigravity.dual_mode", + "visibility": "(100) * antigravity.external.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.weekly.expected_display, 0, 100) / 100)", + "y": "(32) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-external-dual-light", "name": "light Antigravity External dual weekly segments", @@ -1800,6 +2398,32 @@ "segment_gap": "1" } }, + { + "id": "antigravity-weekly-external-dual-light-pace", + "name": "light Antigravity External dual weekly segments pace tick", + "render": "(1 - system.dark) && antigravity.dual_mode", + "visibility": "(100) * antigravity.external.weekly.expected_available * display.show_pace", + "parent": "antigravity-provider", + "x": "round((ceil(10 / max(1, providers.count)) * 11 - 2) * clamp(antigravity.external.weekly.expected_display, 0, 100) / 100)", + "y": "(32) - 1", + "width": "1", + "height": "(4) + 2", + "background": { + "type": "colour", + "colour": { + "color": "#1A1A1AFF", + "opacity": "0.9" + } + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, { "id": "antigravity-weekly-gemini-value-dark", "name": "dark Antigravity Gemini weekly value", diff --git a/src/window.rs b/src/window.rs index db350b53..a6387920 100644 --- a/src/window.rs +++ b/src/window.rs @@ -136,6 +136,7 @@ struct AppState { custom_theme_enabled: bool, usage_countdown: bool, + show_pace: bool, active_theme_path: Option, active_theme: Option, theme_clock_interval: Option, @@ -206,6 +207,7 @@ const IDM_FREQ_15MIN: u16 = 12; const IDM_FREQ_1HOUR: u16 = 13; const IDM_START_WITH_WINDOWS: u16 = 20; const IDM_LOCK_TASKBAR: u16 = 21; +const IDM_SHOW_PACE: u16 = 22; const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 100; const IDM_LANG_FIRST: u16 = 101; @@ -626,6 +628,7 @@ fn theme_runtime_from_state(state: &AppState) -> ThemeRuntime { .with_poll_state(poll_ok, has_error) .with_language(state.language) .with_countdown(state.usage_countdown) + .with_show_pace(state.show_pace) .with_nest(nest) .with_floating_card_opacity(opacity) .with_antigravity_pools( @@ -855,6 +858,7 @@ fn save_state_settings() { persisted.show_antigravity_gemini = Some(antigravity_on && s.show_antigravity_gemini); persisted.show_antigravity_external = Some(antigravity_on && s.show_antigravity_external); persisted.custom_theme_enabled = s.custom_theme_enabled; + persisted.show_pace = s.show_pace; persisted.active_theme_path = s .active_theme_path .as_ref() @@ -2213,7 +2217,8 @@ pub fn run() { let initial_runtime = ThemeRuntime::from_providers(settings.enabled_providers()) .with_poll_state(false, false) .with_language(language) - .with_countdown(settings.usage_countdown); + .with_countdown(settings.usage_countdown) + .with_show_pace(settings.show_pace); let (initial_width, initial_height) = active_theme .as_ref() .map(|theme| { @@ -2314,6 +2319,7 @@ pub fn run() { window_state_timer_active: false, custom_theme_enabled, usage_countdown: settings.usage_countdown, + show_pace: settings.show_pace, active_theme_path, active_theme, theme_clock_interval, @@ -3054,6 +3060,7 @@ fn reload_external_settings(hwnd: HWND) { state.poll_interval_ms = settings.poll_interval_ms; state.providers = settings.enabled_providers(); state.usage_countdown = settings.usage_countdown; + state.show_pace = settings.show_pace; dual_mode = state.show_antigravity_gemini && state.show_antigravity_external && state.providers.contains(ProviderId::Antigravity); diff --git a/src/window/message_loop.rs b/src/window/message_loop.rs index 67def3b1..f0caac8b 100644 --- a/src/window/message_loop.rs +++ b/src/window/message_loop.rs @@ -842,6 +842,17 @@ pub(super) unsafe extern "system" fn wnd_proc( IDM_LOCK_TASKBAR => { toggle_taskbar_lock(hwnd); } + IDM_SHOW_PACE => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_pace = !s.show_pace; + } + } + save_state_settings(); + render_layered(); + sync_tray_icon(hwnd); + } IDM_FREQ_1MIN | IDM_FREQ_5MIN | IDM_FREQ_15MIN | IDM_FREQ_1HOUR => { let new_interval = match id { IDM_FREQ_1MIN => POLL_1_MIN, diff --git a/src/window/placement_regression_tests.rs b/src/window/placement_regression_tests.rs index 6f0727e2..f7b8789a 100644 --- a/src/window/placement_regression_tests.rs +++ b/src/window/placement_regression_tests.rs @@ -48,6 +48,7 @@ fn state_for(theme: ThemeDocument, placement: PlacementOverride) -> AppState { window_state_timer_active: false, custom_theme_enabled: true, usage_countdown: false, + show_pace: true, active_theme_path: None, active_theme: Some(theme), theme_clock_interval: None, diff --git a/src/window/window_context_menu.rs b/src/window/window_context_menu.rs index 130c0985..8108241c 100644 --- a/src/window/window_context_menu.rs +++ b/src/window/window_context_menu.rs @@ -149,6 +149,7 @@ pub(super) fn context_menu_action_flags( ContextMenuAction::ToggleAntigravityExternal => state.show_antigravity_external, ContextMenuAction::ToggleStartup => is_startup_enabled(), ContextMenuAction::ToggleTaskbarLock => state.lock_taskbar, + ContextMenuAction::ToggleShowPace => state.show_pace, ContextMenuAction::ToggleWidget => state .active_theme .as_ref() @@ -273,6 +274,7 @@ pub(super) fn execute_context_menu_action( ContextMenuAction::ToggleAntigravityExternal => Some(IDM_PROVIDER_ANTIGRAVITY_EXTERNAL), ContextMenuAction::ToggleStartup => Some(IDM_START_WITH_WINDOWS), ContextMenuAction::ToggleTaskbarLock => Some(IDM_LOCK_TASKBAR), + ContextMenuAction::ToggleShowPace => Some(IDM_SHOW_PACE), ContextMenuAction::SetLanguage { language } => { if language.eq_ignore_ascii_case("system") { Some(IDM_LANG_SYSTEM) From fe4941da3d37c5b60ff9d12a4611653b4c2dc6ce Mon Sep 17 00:00:00 2001 From: fix2it Date: Mon, 19 Oct 2026 13:40:38 +0300 Subject: [PATCH 6/6] fix(compact): keep the pace tick when a reset is reported slightly late Allow a minute of slack past the window length so poll latency right after a reset does not hide the tick; it sits at 0 instead. Co-Authored-By: Claude Opus 5.5 --- src/theme_engine.rs | 6 ++++-- src/theme_engine/tests.rs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/theme_engine.rs b/src/theme_engine.rs index 6df9ee96..4ec1964b 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1472,8 +1472,10 @@ fn expected_used(resets_at: std::time::SystemTime, length: std::time::Duration) let left = resets_at .duration_since(std::time::SystemTime::now()) .ok()?; - (!length.is_zero() && left <= length) - .then(|| (1.0 - left.as_secs_f64() / length.as_secs_f64()) * 100.0) + // A minute of slack for poll latency right after a reset. + (!length.is_zero() && left <= length + std::time::Duration::from_secs(60)).then(|| { + (1.0 - left.min(length).as_secs_f64() / length.as_secs_f64()) * 100.0 + }) } impl DataContext { diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 08a2fedb..fd0304f7 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -3486,8 +3486,10 @@ fn pace_expected_used_rejects_resets_outside_the_window() { let half = expected_used(now + length / 2, length).unwrap(); assert!((half - 50.0).abs() < 0.1, "{half}"); assert_eq!(expected_used(SystemTime::UNIX_EPOCH, length), None); + // A reset reported slightly past the window start still ticks at 0. + assert_eq!(expected_used(now + length + Duration::from_secs(30), length), Some(0.0)); assert_eq!( - expected_used(now + length + Duration::from_secs(60), length), + expected_used(now + length + Duration::from_secs(300), length), None ); } @@ -3563,7 +3565,7 @@ fn pace_bindings_hide_missing_windows_and_reset_times() { (true, None), (false, Some(now + Duration::from_secs(60))), (true, Some(SystemTime::UNIX_EPOCH)), - (true, Some(now + WEEK + Duration::from_secs(60))), + (true, Some(now + WEEK + Duration::from_secs(300))), ] { let section = UsageSection { available,