From cd91c119c234740a4ac2c23f6f423ddb8d2e520c Mon Sep 17 00:00:00 2001 From: articdevil03 <11657650+articdevil03@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:46:36 +0800 Subject: [PATCH] feat(themes): add Top Bar theme, hide-until-hover placement and 5% low-usage alarm Adds an optional built-in "Top Bar" theme: a wide, short strip docked at the top centre of a chosen monitor that stays collapsed as a small click-through tab and slides down when the cursor rests on it. One block per enabled provider, left to right, with %, a thin bar and a "Resets in" countdown; follows Windows light/dark. Theme engine (usable by any theme): an auto_hide placement option for edge-docked floating roots, handle_color, a countdown number format, data.updated.* bindings, light/dark colour expressions, alarm.* bindings and a snooze_alarm() click action. Settings > Display gains Hide until hover (Use theme setting / Enabled / Disabled), Widget display and Alarm at 5% remaining; Studio shows and can clear an active override and previews colour expressions. The 5% low-usage alarm covers each enabled provider's and account's headline windows plus Claude model caps, plays a bundled original WAV at most once a minute, keeps edge-docked surfaces revealed while a quota is low, and offers Snooze 1h on the bar and in the context menu. The Top Bar theme JSON is generated by tools/gen_top_bar.py. Also fixes a test that assumed more than a day of uptime (poller/retry_after.rs) and a clippy lint in window/taskbar_collision.rs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011HqUMQbM9NB7PuC292Sh69 --- .gitignore | 3 + USER_GUIDE.md | 101 +- build.rs | 1 + docs/images/top-bar-alarm.png | Bin 0 -> 38880 bytes docs/images/top-bar-dark.png | Bin 0 -> 35105 bytes docs/images/top-bar-light.png | Bin 0 -> 46923 bytes src/app_settings.rs | 124 +- src/assets/README.md | 5 + src/assets/alarm-warning-pulse.wav | Bin 0 -> 84716 bytes src/context_menu.rs | 35 + src/localization/locales/de.toml | 15 + src/localization/locales/en.toml | 15 + src/localization/locales/es.toml | 15 + src/localization/locales/fr.toml | 15 + src/localization/locales/ja.toml | 15 + src/localization/locales/ko.toml | 15 + src/localization/locales/nl.toml | 15 + src/localization/locales/pl.toml | 15 + src/localization/locales/pt-BR.toml | 15 + src/localization/locales/ru.toml | 15 + src/localization/locales/th.toml | 15 + src/localization/locales/tr.toml | 15 + src/localization/locales/zh-CN.toml | 15 + src/localization/locales/zh-TW.toml | 15 + src/localization/mod.rs | 5 + src/models.rs | 5 + src/native_interop.rs | 13 + src/poller/retry_after.rs | 11 +- src/studio_app.rs | 1 + src/studio_app/studio_context_menu_editor.rs | 2 + src/studio_app/studio_core.rs | 14 +- src/studio_app/studio_helper.rs | 28 + src/studio_app/studio_helper_panels.rs | 4 + src/studio_app/studio_inspectors.rs | 31 +- src/studio_app/studio_scene_helpers.rs | 42 +- src/studio_app/studio_settings.rs | 162 + src/studio_app/studio_theme_workspace.rs | 36 + src/studio_app/tests.rs | 175 +- src/theme_engine.rs | 277 +- src/theme_engine/tests.rs | 13 +- src/theme_engine/theme_expression.rs | 33 + src/theme_engine/theme_limits.rs | 223 +- src/theme_engine/theme_rendering.rs | 161 +- src/theme_engine/top_bar_tests.rs | 1719 ++++++++ src/themes/top-bar.json | 3704 ++++++++++++++++++ src/ui/components/color_picker.rs | 7 +- src/window.rs | 229 +- src/window/auto_hide.rs | 501 +++ src/window/low_usage_alarm.rs | 673 ++++ src/window/message_loop.rs | 12 +- src/window/mouse.rs | 1 + src/window/placement_regression_tests.rs | 678 ++++ src/window/placement_tests.rs | 299 ++ src/window/positioning.rs | 150 +- src/window/taskbar_collision.rs | 2 +- src/window/window_context_menu.rs | 7 +- tools/gen_top_bar.py | 526 +++ 57 files changed, 10066 insertions(+), 157 deletions(-) create mode 100644 docs/images/top-bar-alarm.png create mode 100644 docs/images/top-bar-dark.png create mode 100644 docs/images/top-bar-light.png create mode 100644 src/assets/README.md create mode 100644 src/assets/alarm-warning-pulse.wav create mode 100644 src/theme_engine/top_bar_tests.rs create mode 100644 src/themes/top-bar.json create mode 100644 src/window/auto_hide.rs create mode 100644 src/window/low_usage_alarm.rs create mode 100644 tools/gen_top_bar.py diff --git a/.gitignore b/.gitignore index bb8ab437..a3f91086 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ test_bin/ # Local dev builds and scratch /builds/ /scratch/ + +# Approval screenshots rendered from fixture data +/approval/ diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e30aeb4f..1543a5ca 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -10,6 +10,7 @@ The instructions below use the app's English labels. - [Duplicate and customise a built-in theme](#duplicate-and-customise-a-built-in-theme) - [Choose providers and refresh usage](#choose-providers-and-refresh-usage) - [Show used or remaining allowance](#show-used-or-remaining-allowance) +- [Use the Top Bar](#use-the-top-bar) - [Windows VM testing for developers](#windows-vm-testing-for-developers) ## Open the dashboard @@ -107,11 +108,107 @@ support both directions. Custom themes need to support this setting too; a theme that always displays consumed usage may stay unchanged. See the [theme binding notes](README.md#usage) if you are editing usage expressions. +## Use the Top Bar + +The **Top Bar** built-in theme is a flat card at the top centre of a monitor, +white in Windows light mode and near-black in dark mode. It hides behind a slim +tab until you rest the pointer on that tab, then slides down. It slides back up +shortly after the pointer leaves. Holding the mouse button, for example while +dragging a window to the top of the screen, never opens it. The tab turns +amber or red when a shown limit reaches 70% or 90%. + +Each enabled provider gets a block, left to right, with one cell per limit: its +usage, a bar, and the time until it resets. A limit at 70% or more sits on an +amber pill, and at 90% or more on a red one. Claude also shows its Fable weekly +cap when the API reports one. The right end shows a green **Live** pill with +how long ago the figures were refreshed, or an amber **Stale** pill. + +![Top Bar in Windows light mode with three providers](docs/images/top-bar-light.png) + +![Top Bar in Windows dark mode with two providers](docs/images/top-bar-dark.png) + +1. Choose **Top Bar** under **Settings > Appearance > Active theme**. +2. Under **Settings > Display**, use **Hide until hover** to keep the bar + always visible instead (**Use theme setting** hands the choice back to the + theme), and **Widget display** to choose its monitor. + +Right-click the open bar for the menu, or double-click it to open the dashboard. + +### Alarm at 5% remaining + +When a limit has 5% or less left, the app plays an alarm once and marks that +limit with a solid red pill wherever the theme shows it. The alarm does not +depend on the theme: a quota the active theme does not show can still hold the +bar open, and **Snooze 1h** dismisses it. + +![Top Bar with the 5-hour limit in alarm](docs/images/top-bar-alarm.png) + +What the alarm covers: + +- For every enabled provider and every account, the headline windows exactly + as the poller fills them (the session or five-hour, weekly and monthly + slots: the same numbers the bars show), plus Claude model caps under the + existing unambiguous `model.` rule. +- Sub-quotas a poller does not surface as a headline window, for example + Copilot's metered Chat and Completions or Antigravity's non-preferred model + groups, are out of scope. +- The sound plays at most once a minute, even when several limits cross. A + limit crossing sooner still holds the bar open and ends a snooze, silently. +- A crossing clears when a good reading shows the quota above 5% remaining, or + when its reset time has passed by more than 5 minutes (a grace for a slightly + fast clock). A fresh low reading always counts, but a stale one kept from + a failed poll never starts a crossing and only keeps one until its reset time + has passed by more than 5 minutes. A reading without a reset time keeps the + last one seen. Turning the alarm or a provider off and on + again forgets it on purpose, so a still-low quota alarms again (the once a + minute sound limit still applies). +- Known limit: two Claude caps that share a model name have no stable model + shortcut, so such a cap clears at its reset. + +The sound is the bundled **warning pulse**. Only if that cannot be played does +the app fall back to **Alarm 1** from your Windows sound scheme, or **Critical +Stop** if the scheme has none. With a scheme that has neither, such as **No +Sounds**, the alarm stays silent, and it is silent whenever Windows itself is +muted. While the limit stays that low, the Top Bar stays down even with **Hide until hover** on. It hides +normally again once no limit is that low, for example after the reset. The +alarm counts what is left, whichever way **Usage direction** shows it. + +- To let the bar hide while you keep working, click **Snooze 1h** on the bar, + or right-click it and choose **Snooze alarm (1 hour)**. Another limit + reaching 5% during the snooze alarms again at once. +- To turn the alarm off, use **Alarm at 5% remaining** under + **Settings > Display**. It is on by default. + +### Theme authors + +Any floating root docked to a monitor's top or bottom edge can hide the same +way with **Hide until hover** in Theme Studio's **Positioning** section. In the +theme file this is `"auto_hide": true` in the root's `placement`. Optional +`handle_color` and `handle_background` expressions return the tab's accent and +fill as `#RRGGBB` or `#RRGGBBAA` text, and a non-zero `force_reveal` expression +keeps the surface down, for example `"force_reveal": "alarm.active"`. + +- Any colour in a theme can be such an expression, for example + `if(system.dark, "#19191C", "#FFFFFF")` to follow Windows light and dark mode. +- `alarm.active` is 1 while any limit has 5% or less left and the alarm is + not snoozed; `alarm.snoozed` and `alarm.enabled` report the snooze and the + setting. Each limit also has `.alarm`, such as `claude.five_hour.alarm` or + `claude.model.fable.alarm`, which marks the quota wherever the theme shows + it. A quota stays counted until it recovers or resets, not while it is + hidden by a theme or account switch (see the contract above). +- The `snooze_alarm()` mouse action does what **Snooze alarm (1 hour)** does, + for a button rendered with `alarm.active`. +- Text templates can use the `countdown` format (`<1m`, `14m`, `2h 14m`, + `3d 4h`), and `data.updated.seconds` gives the time since usage was last + refreshed (`data.updated.available` is 0 until it is known). Themes using + either refresh once a minute. + ## Claude extra limits in custom themes Claude may report extra quotas in its usage API, including model-specific caps. -These are available to custom themes. Built-in themes and the existing session, -weekly, and headline bindings retain their current behaviour. +These are available to custom themes. Built-in themes other than Top Bar, and +the existing session, weekly, and headline bindings, retain their current +behaviour. After refreshing Claude usage, open the text editor's **Provider values** list or the expression editor's **Variables** panel and look under **Claude Code**. diff --git a/build.rs b/build.rs index cac09b1c..1a05d0f1 100644 --- a/build.rs +++ b/build.rs @@ -44,6 +44,7 @@ fn build_themes() { "classic-usage-widget.json", "compact-fluent-quad.json", "classic-vertical.json", + "top-bar.json", "minecraft-codex.json", ] { let path = Path::new("src/themes").join(name); diff --git a/docs/images/top-bar-alarm.png b/docs/images/top-bar-alarm.png new file mode 100644 index 0000000000000000000000000000000000000000..eace9993a2c708906d22fe267018bc590c60132e GIT binary patch literal 38880 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 NZp`G-2S(0I_yanEV$uKr literal 0 HcmV?d00001 diff --git a/docs/images/top-bar-light.png b/docs/images/top-bar-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e03daddf57a7e11989e09a31bf1cedfd2c9a8751 GIT binary patch literal 46923 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 H-naZ80a$tO literal 0 HcmV?d00001 diff --git a/src/app_settings.rs b/src/app_settings.rs index c7b833fb..d9bb1223 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -82,6 +82,16 @@ pub struct SettingsFile { pub lock_taskbar: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub placement_override: Option, + /// Whether edge-docked widgets hide until hovered; `None` keeps the theme's choice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hide_until_hover: Option, + /// Display index for edge-docked widgets; `None` keeps the theme's choice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edge_display: Option, + /// Sound an alarm and keep edge-docked widgets revealed while a shown + /// limit has 5% or less left. + #[serde(default = "default_true")] + pub low_usage_alarm: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -135,6 +145,9 @@ impl Default for SettingsFile { floating_card_opacity: None, lock_taskbar: false, placement_override: None, + hide_until_hover: None, + edge_display: None, + low_usage_alarm: true, } } } @@ -245,10 +258,33 @@ impl SettingsFile { #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct UsageCache { pub updated_unix: u64, + /// When a poll last delivered at least one non-stale reading. Missing (outer + /// `None`) in caches written before this field existed; `null` (`Some(None)`) + /// means this build knows there has been no fresh reading. + #[serde( + default, + deserialize_with = "present_or_null", + skip_serializing_if = "Option::is_none" + )] + pub fresh_unix: Option>, pub poll_ok: bool, pub data: AppUsageData, } +fn present_or_null<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Option::::deserialize(deserializer).map(Some) +} + +impl UsageCache { + /// The time the figures were last really refreshed, for "updated N ago"; + /// `None` when this build recorded that no reading was ever fresh. + pub fn fresh_or_updated_unix(&self) -> Option { + self.fresh_unix.unwrap_or(Some(self.updated_unix)) + } +} + #[cfg(not(test))] pub fn app_data_directory() -> PathBuf { let root = std::env::var_os("APPDATA") @@ -372,11 +408,19 @@ pub fn load_usage_cache() -> Option { Some(cache) } -pub fn save_usage_cache(data: &AppUsageData, poll_ok: bool) -> Result<(), String> { +/// `fresh_unix` is the app's actual last-fresh time (decided from the incoming +/// poll, not from the merged data); `None` is stored as `null` ("unknown"). +pub fn save_usage_cache( + data: &AppUsageData, + poll_ok: bool, + fresh_unix: Option, +) -> Result<(), String> { + let now = now_unix(); write_json_atomic( &usage_cache_path(), &UsageCache { - updated_unix: now_unix(), + updated_unix: now, + fresh_unix: Some(fresh_unix), poll_ok, data: data.clone(), }, @@ -444,6 +488,46 @@ fn now_unix() -> u64 { mod tests { use super::*; + #[test] + fn the_cache_stores_the_fresh_time_it_is_given() { + let data = AppUsageData::default(); + save_usage_cache(&data, true, Some(1234)).unwrap(); + let cache = load_usage_cache().unwrap(); + assert_eq!(cache.fresh_or_updated_unix(), Some(1234)); + save_usage_cache(&data, true, None).unwrap(); + let cache = load_usage_cache().unwrap(); + assert!(cache.updated_unix > 0); + assert_eq!(cache.fresh_or_updated_unix(), None); + } + + #[test] + fn a_legacy_cache_without_the_field_falls_back_to_updated_unix() { + let old: UsageCache = + serde_json::from_str(r#"{"updated_unix":7,"poll_ok":true,"data":{}}"#).unwrap(); + assert_eq!(old.fresh_or_updated_unix(), Some(7)); + let explicit: UsageCache = serde_json::from_str( + r#"{"updated_unix":7,"fresh_unix":null,"poll_ok":true,"data":{}}"#, + ) + .unwrap(); + assert_eq!(explicit.fresh_or_updated_unix(), None); + } + + #[test] + fn fresh_time_semantics_survive_a_serialize_reload() { + let legacy: UsageCache = + serde_json::from_str(r#"{"updated_unix":7,"poll_ok":true,"data":{}}"#).unwrap(); + let json = serde_json::to_string(&legacy).unwrap(); + let reloaded: UsageCache = serde_json::from_str(&json).unwrap(); + assert_eq!(reloaded.fresh_or_updated_unix(), Some(7)); + let unknown: UsageCache = serde_json::from_str( + r#"{"updated_unix":7,"fresh_unix":null,"poll_ok":true,"data":{}}"#, + ) + .unwrap(); + let json = serde_json::to_string(&unknown).unwrap(); + let reloaded: UsageCache = serde_json::from_str(&json).unwrap(); + assert_eq!(reloaded.fresh_or_updated_unix(), None); + } + #[test] fn taskbar_lock_is_opt_in_and_survives_settings_round_trip() { assert!(!decode_settings("{}").unwrap().lock_taskbar); @@ -457,6 +541,42 @@ mod tests { } } + #[test] + fn edge_widget_preferences_are_optional_and_round_trip() { + let defaults = decode_settings("{}").unwrap(); + assert_eq!( + (defaults.hide_until_hover, defaults.edge_display), + (None, None) + ); + let json = settings_json(&defaults); + assert!(json.get("hide_until_hover").is_none() && json.get("edge_display").is_none()); + let settings = SettingsFile { + hide_until_hover: Some(false), + edge_display: Some(1), + ..Default::default() + }; + let decoded = decode_settings(&settings_json(&settings).to_string()).unwrap(); + assert_eq!( + (decoded.hide_until_hover, decoded.edge_display), + (Some(false), Some(1)) + ); + } + + #[test] + fn low_usage_alarm_defaults_on_and_round_trips_off() { + assert!(decode_settings("{}").unwrap().low_usage_alarm); + assert!(SettingsFile::default().low_usage_alarm); + let off = SettingsFile { + low_usage_alarm: false, + ..Default::default() + }; + assert!( + !decode_settings(&settings_json(&off).to_string()) + .unwrap() + .low_usage_alarm + ); + } + #[test] fn application_files_stay_inside_the_test_directory() { let root = app_data_directory(); diff --git a/src/assets/README.md b/src/assets/README.md new file mode 100644 index 00000000..4d306e86 --- /dev/null +++ b/src/assets/README.md @@ -0,0 +1,5 @@ +# Bundled assets + +- `alarm-warning-pulse.wav`: the low-usage alarm sound ("warning pulse", 44.1 kHz 16-bit mono PCM, + ~0.96 s, a 660/880 Hz two-tone played 3 times). Synthesized from scratch for this project: + no third-party audio, no licence restrictions. diff --git a/src/assets/alarm-warning-pulse.wav b/src/assets/alarm-warning-pulse.wav new file mode 100644 index 0000000000000000000000000000000000000000..c2e7d92f202725f682898ff95fb618deee74a017 GIT binary patch literal 84716 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 language.text("Check for updates"), "Lock in taskbar" => language.text("Lock in taskbar"), "Show widget" => language.text("Show widget"), + "Snooze alarm (1 hour)" => language.text("Snooze alarm (1 hour)"), _ => label, }; crate::theme_engine::format_template(translated, context) @@ -462,11 +465,23 @@ pub fn classic_context_menu() -> ContextMenuDocument { ], ); + // Shown only while an almost spent limit is holding the widget open. + let snooze = ContextMenuItem { + render: Expression("alarm.active".into()), + ..ContextMenuItem::action("snooze-alarm", "Snooze alarm (1 hour)", Action::SnoozeAlarm) + }; + let snooze_separator = ContextMenuItem { + render: Expression("alarm.active".into()), + ..ContextMenuItem::separator("snooze-separator") + }; + ContextMenuDocument { schema_version: CONTEXT_MENU_SCHEMA_VERSION, id: CLASSIC_CONTEXT_MENU_ID.into(), name: "Classic v1".into(), items: vec![ + snooze, + snooze_separator, ContextMenuItem::action("refresh", "Refresh", Action::Refresh), frequency, providers, @@ -742,6 +757,26 @@ mod tests { assert!(legacy.is_builtin()); } + #[test] + fn classic_menu_offers_the_alarm_snooze_only_while_it_is_active() { + let menu = classic_context_menu(); + let snooze = &menu.items[0]; + assert_eq!(snooze.label, "Snooze alarm (1 hour)"); + assert_eq!( + snooze.kind, + ContextMenuItemKind::Action { + action: ContextMenuAction::SnoozeAlarm + } + ); + let mut context = DataContext::from_usage(None, &theme_engine::Canvas::default()); + assert_eq!(context.get("alarm.active"), Some(0.0)); + assert!(!snooze.should_render(&context) && !menu.items[1].should_render(&context)); + context.insert("alarm.active", 1.0); + assert!(snooze.should_render(&context) && menu.items[1].should_render(&context)); + let json = serde_json::to_string(&ContextMenuAction::SnoozeAlarm).unwrap(); + assert_eq!(json, r#"{"type":"snooze_alarm"}"#); + } + #[test] fn dashboard_menu_is_builtin_and_separates_its_actions() { let menu = dashboard_v2_context_menu(); diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index df432b1e..e9512d47 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -45,6 +45,11 @@ day_suffix = "T" hour_suffix = "h" minute_suffix = "m" second_suffix = "s" +status_waiting = "Warte auf Nutzungsdaten" +status_live = "Live" +status_stale = "Veraltet" +status_live_updated = "Live · vor {time} aktualisiert" +status_stale_updated = "Veraltet · vor {time} aktualisiert" token_expired_title = "Authentifizierungsfehler" token_expired_body = "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu." codex_token_expired_title = "Codex-Authentifizierungsfehler" @@ -457,3 +462,13 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Bis zum Darüberfahren ausblenden" +"Keep edge-docked widgets tucked away until you point at them" = "Am Rand angedockte Widgets verbergen, bis der Mauszeiger darauf zeigt" +"Widget display" = "Widget-Bildschirm" +"Monitor used by edge-docked widgets" = "Bildschirm für am Rand angedockte Widgets" +"Countdown" = "Countdown" +"Alarm at 5% remaining" = "Alarm bei 5 % Rest" +"Use theme setting" = "Theme-Einstellung verwenden" +"Overridden in Settings" = "In den Einstellungen überschrieben" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Einmal einen Ton abspielen und am Rand angedockte Widgets geöffnet lassen, solange ein Limit fast aufgebraucht ist" +"Snooze alarm (1 hour)" = "Alarm 1 Stunde stummschalten" diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index 2273418e..7b9d938a 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -45,6 +45,11 @@ day_suffix = "d" hour_suffix = "h" minute_suffix = "m" second_suffix = "s" +status_waiting = "Waiting for usage data" +status_live = "Live" +status_stale = "Stale" +status_live_updated = "Live · updated {time} ago" +status_stale_updated = "Stale · updated {time} ago" token_expired_title = "Claude Code Auth Error" token_expired_body = "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app." codex_token_expired_title = "Codex Auth Error" @@ -457,3 +462,13 @@ copilot_window_title = "Copilot Usage Monitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Hide until hover" +"Keep edge-docked widgets tucked away until you point at them" = "Keep edge-docked widgets tucked away until you point at them" +"Widget display" = "Widget display" +"Monitor used by edge-docked widgets" = "Monitor used by edge-docked widgets" +"Countdown" = "Countdown" +"Alarm at 5% remaining" = "Alarm at 5% remaining" +"Use theme setting" = "Use theme setting" +"Overridden in Settings" = "Overridden in Settings" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Sound once and keep edge-docked widgets open while a limit is almost spent" +"Snooze alarm (1 hour)" = "Snooze alarm (1 hour)" diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 41ac2ed6..c7a3f91d 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -45,6 +45,11 @@ day_suffix = "d" hour_suffix = "h" minute_suffix = "m" second_suffix = "s" +status_waiting = "Esperando datos de uso" +status_live = "En vivo" +status_stale = "Obsoleto" +status_live_updated = "En vivo · actualizado hace {time}" +status_stale_updated = "Obsoleto · actualizado hace {time}" token_expired_title = "Error de autenticación" token_expired_body = "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación." codex_token_expired_title = "Error de autenticacion de Codex" @@ -457,3 +462,13 @@ copilot_window_title = "Monitor de uso de Copilot" "Lock in taskbar" = "Fijar en la barra" "Keep the widget docked; never float automatically" = "Mantener el widget acoplado; no flotar automáticamente" "Locked" = "Fijado" +"Hide until hover" = "Ocultar hasta pasar el ratón" +"Keep edge-docked widgets tucked away until you point at them" = "Mantener ocultos los widgets acoplados al borde hasta que apuntes a ellos" +"Widget display" = "Pantalla del widget" +"Monitor used by edge-docked widgets" = "Monitor usado por los widgets acoplados al borde" +"Countdown" = "Cuenta atrás" +"Alarm at 5% remaining" = "Alarma al 5 % restante" +"Use theme setting" = "Usar la configuración del tema" +"Overridden in Settings" = "Anulado en Ajustes" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Sonar una vez y mantener abiertos los widgets acoplados al borde mientras un límite esté casi agotado" +"Snooze alarm (1 hour)" = "Posponer alarma (1 hora)" diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index 34545dcf..da39aad6 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -45,6 +45,11 @@ day_suffix = "j" hour_suffix = "h" minute_suffix = "m" second_suffix = "s" +status_waiting = "En attente des données" +status_live = "En direct" +status_stale = "Obsolète" +status_live_updated = "En direct · mis à jour il y a {time}" +status_stale_updated = "Obsolète · mis à jour il y a {time}" token_expired_title = "Erreur d'authentification" token_expired_body = "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application." codex_token_expired_title = "Erreur d'authentification Codex" @@ -457,3 +462,13 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Masquer jusqu'au survol" +"Keep edge-docked widgets tucked away until you point at them" = "Garder les widgets ancrés au bord masqués jusqu'à ce que vous les pointiez" +"Widget display" = "Écran du widget" +"Monitor used by edge-docked widgets" = "Écran utilisé par les widgets ancrés au bord" +"Countdown" = "Compte à rebours" +"Alarm at 5% remaining" = "Alarme à 5 % restants" +"Use theme setting" = "Utiliser le réglage du thème" +"Overridden in Settings" = "Remplacé dans les réglages" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Émettre un son une fois et garder ouverts les widgets ancrés au bord tant qu'une limite est presque épuisée" +"Snooze alarm (1 hour)" = "Reporter l'alarme (1 heure)" diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 6d6de298..1bf2fba5 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -47,6 +47,11 @@ day_suffix = "日" hour_suffix = "時間" minute_suffix = "分" second_suffix = "秒" +status_waiting = "使用量を待っています" +status_live = "ライブ" +status_stale = "古い" +status_live_updated = "ライブ · {time}前に更新" +status_stale_updated = "古い · {time}前に更新" token_expired_title = "認証エラー" token_expired_body = "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。" codex_token_expired_title = "Codex 認証エラー" @@ -459,3 +464,13 @@ copilot_window_title = "Copilot 使用量モニター" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "ホバーするまで隠す" +"Keep edge-docked widgets tucked away until you point at them" = "端にドッキングしたウィジェットを、ポインターを合わせるまで隠します" +"Widget display" = "ウィジェットのディスプレイ" +"Monitor used by edge-docked widgets" = "端にドッキングしたウィジェットを表示するモニター" +"Countdown" = "カウントダウン" +"Alarm at 5% remaining" = "残り5%でアラーム" +"Use theme setting" = "テーマの設定を使う" +"Overridden in Settings" = "設定で上書きされています" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "上限がほぼ使い切られたら一度だけ音を鳴らし、端にドッキングしたウィジェットを開いたままにします" +"Snooze alarm (1 hour)" = "アラームを1時間スヌーズ" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index 6d73478c..0cb3b0b6 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -47,6 +47,11 @@ day_suffix = "일" hour_suffix = "시간" minute_suffix = "분" second_suffix = "초" +status_waiting = "사용량 데이터 대기 중" +status_live = "실시간" +status_stale = "오래됨" +status_live_updated = "실시간 · {time} 전 업데이트" +status_stale_updated = "오래됨 · {time} 전 업데이트" token_expired_title = "인증 오류" token_expired_body = "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요." codex_token_expired_title = "Codex 인증 오류" @@ -459,3 +464,13 @@ copilot_window_title = "Copilot 사용량 모니터" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "마우스를 올릴 때까지 숨기기" +"Keep edge-docked widgets tucked away until you point at them" = "가장자리에 고정된 위젯을 포인터를 올릴 때까지 숨깁니다" +"Widget display" = "위젯 디스플레이" +"Monitor used by edge-docked widgets" = "가장자리에 고정된 위젯이 사용할 모니터" +"Countdown" = "카운트다운" +"Alarm at 5% remaining" = "5% 남으면 알람" +"Use theme setting" = "테마 설정 사용" +"Overridden in Settings" = "설정에서 재정의됨" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "한도를 거의 다 쓰면 한 번 소리를 내고 가장자리에 고정된 위젯을 열어 둡니다" +"Snooze alarm (1 hour)" = "알람 1시간 다시 알림" diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index 50550807..136fa040 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -45,6 +45,11 @@ day_suffix = "d" hour_suffix = "u" minute_suffix = "m" second_suffix = "s" +status_waiting = "Wachten op gebruiksgegevens" +status_live = "Live" +status_stale = "Verouderd" +status_live_updated = "Live · {time} geleden bijgewerkt" +status_stale_updated = "Verouderd · {time} geleden bijgewerkt" token_expired_title = "Claude Code-authenticatiefout" token_expired_body = "Voer 'claude' uit in een terminal, gebruik daarna '/login' en volg de stappen. Ververs of herstart de app daarna." codex_token_expired_title = "Codex-authenticatiefout" @@ -457,3 +462,13 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Verbergen tot aanwijzen" +"Keep edge-docked widgets tucked away until you point at them" = "Aan de rand gekoppelde widgets verborgen houden tot je ze aanwijst" +"Widget display" = "Widgetscherm" +"Monitor used by edge-docked widgets" = "Monitor voor aan de rand gekoppelde widgets" +"Countdown" = "Aftellen" +"Alarm at 5% remaining" = "Alarm bij 5% over" +"Use theme setting" = "Thema-instelling gebruiken" +"Overridden in Settings" = "Overschreven in Instellingen" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Eén keer een geluid afspelen en aan de rand gekoppelde widgets open houden zolang een limiet bijna op is" +"Snooze alarm (1 hour)" = "Alarm 1 uur uitstellen" diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index c743ce77..707fee4c 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -45,6 +45,11 @@ day_suffix = "d" hour_suffix = "h" minute_suffix = "m" second_suffix = "s" +status_waiting = "Oczekiwanie na dane użycia" +status_live = "Na żywo" +status_stale = "Nieaktualne" +status_live_updated = "Na żywo · zaktualizowano {time} temu" +status_stale_updated = "Nieaktualne · zaktualizowano {time} temu" token_expired_title = "Błąd uwierzytelniania Claude Code" token_expired_body = "Uruchom polecenie „claude” w terminalu, następnie użyj „/login” i postępuj zgodnie z instrukcjami. Potem odśwież lub uruchom tę aplikację ponownie." codex_token_expired_title = "Błąd uwierzytelniania Codex" @@ -457,3 +462,13 @@ copilot_window_title = "Monitor zużycia Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Ukryj do najechania" +"Keep edge-docked widgets tucked away until you point at them" = "Ukrywaj widżety zadokowane przy krawędzi, dopóki nie wskażesz ich kursorem" +"Widget display" = "Ekran widżetu" +"Monitor used by edge-docked widgets" = "Monitor używany przez widżety zadokowane przy krawędzi" +"Countdown" = "Odliczanie" +"Alarm at 5% remaining" = "Alarm przy 5% pozostałego" +"Use theme setting" = "Użyj ustawienia motywu" +"Overridden in Settings" = "Zastąpione w Ustawieniach" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Odtwórz dźwięk raz i trzymaj widżety zadokowane przy krawędzi otwarte, gdy limit jest prawie wyczerpany" +"Snooze alarm (1 hour)" = "Drzemka alarmu (1 godzina)" diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index 7719834b..69822ce0 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -45,6 +45,11 @@ day_suffix = "d" hour_suffix = "h" minute_suffix = "m" second_suffix = "s" +status_waiting = "Aguardando dados de uso" +status_live = "Ao vivo" +status_stale = "Desatualizado" +status_live_updated = "Ao vivo · atualizado há {time}" +status_stale_updated = "Desatualizado · atualizado há {time}" token_expired_title = "Erro de Autenticação do Claude Code" token_expired_body = "Execute 'claude' em um terminal, use '/login' e siga as instruções. Depois disso, atualize ou reinicie este aplicativo." codex_token_expired_title = "Erro de Autenticação do Codex" @@ -457,3 +462,13 @@ copilot_window_title = "Monitor de uso do Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Ocultar até passar o mouse" +"Keep edge-docked widgets tucked away until you point at them" = "Manter os widgets encaixados na borda ocultos até você apontar para eles" +"Widget display" = "Tela do widget" +"Monitor used by edge-docked widgets" = "Monitor usado pelos widgets encaixados na borda" +"Countdown" = "Contagem regressiva" +"Alarm at 5% remaining" = "Alarme com 5% restantes" +"Use theme setting" = "Usar a configuração do tema" +"Overridden in Settings" = "Substituído nas Configurações" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Tocar um som uma vez e manter abertos os widgets encaixados na borda enquanto um limite estiver quase esgotado" +"Snooze alarm (1 hour)" = "Adiar alarme (1 hora)" diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index 19d8aa49..c006ff36 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -45,6 +45,11 @@ day_suffix = "д" hour_suffix = "ч" minute_suffix = "м" second_suffix = "с" +status_waiting = "Ожидание данных" +status_live = "Онлайн" +status_stale = "Устарело" +status_live_updated = "Онлайн · обновлено {time} назад" +status_stale_updated = "Устарело · обновлено {time} назад" token_expired_title = "Ошибка авторизации Claude Code" token_expired_body = "Запустите 'claude' в терминале, затем используйте '/login' и следуйте инструкциям. После этого обновите или перезапустите приложение." codex_token_expired_title = "Ошибка авторизации Codex" @@ -457,3 +462,13 @@ copilot_window_title = "Монитор использования Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Скрывать до наведения" +"Keep edge-docked widgets tucked away until you point at them" = "Скрывать виджеты у края экрана, пока на них не наведён указатель" +"Widget display" = "Экран виджета" +"Monitor used by edge-docked widgets" = "Монитор для виджетов у края экрана" +"Countdown" = "Обратный отсчёт" +"Alarm at 5% remaining" = "Сигнал при остатке 5%" +"Use theme setting" = "Использовать настройку темы" +"Overridden in Settings" = "Переопределено в настройках" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Один раз подать звук и держать виджеты у края экрана открытыми, пока лимит почти исчерпан" +"Snooze alarm (1 hour)" = "Отложить сигнал (1 час)" diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index c2a936ee..45e89fd6 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -47,6 +47,11 @@ day_suffix = "ว" hour_suffix = "ชม" minute_suffix = "น" second_suffix = "วิ" +status_waiting = "รอข้อมูลการใช้งาน" +status_live = "สด" +status_stale = "ล้าสมัย" +status_live_updated = "สด · อัปเดตเมื่อ {time} ที่แล้ว" +status_stale_updated = "ล้าสมัย · อัปเดตเมื่อ {time} ที่แล้ว" token_expired_title = "Claude Code ยืนยันตัวตนไม่สำเร็จ" token_expired_body = "เปิดเทอร์มินัลแล้วรันคำสั่ง 'claude' จากนั้นใช้ '/login' และทำตามคำแนะนำ เสร็จแล้วให้รีเฟรชหรือเปิดแอปนี้ใหม่" codex_token_expired_title = "Codex ยืนยันตัวตนไม่สำเร็จ" @@ -459,3 +464,13 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "ซ่อนจนกว่าจะชี้เมาส์" +"Keep edge-docked widgets tucked away until you point at them" = "ซ่อนวิดเจ็ตที่ยึดไว้ที่ขอบจอจนกว่าจะชี้เมาส์ไปที่วิดเจ็ต" +"Widget display" = "จอแสดงผลของวิดเจ็ต" +"Monitor used by edge-docked widgets" = "จอภาพที่วิดเจ็ตซึ่งยึดไว้ที่ขอบใช้" +"Countdown" = "นับถอยหลัง" +"Alarm at 5% remaining" = "เตือนเมื่อเหลือ 5%" +"Use theme setting" = "ใช้การตั้งค่าของธีม" +"Overridden in Settings" = "ถูกแทนที่ในการตั้งค่า" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "เล่นเสียงหนึ่งครั้งและเปิดวิดเจ็ตที่ยึดไว้ที่ขอบจอค้างไว้ขณะที่ขีดจำกัดใกล้หมด" +"Snooze alarm (1 hour)" = "เลื่อนการเตือน (1 ชั่วโมง)" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index cf8706c8..548db188 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -45,6 +45,11 @@ day_suffix = "g" hour_suffix = "sa" minute_suffix = "dk" second_suffix = "sn" +status_waiting = "Kullanım verileri bekleniyor" +status_live = "Canlı" +status_stale = "Eski" +status_live_updated = "Canlı · {time} önce güncellendi" +status_stale_updated = "Eski · {time} önce güncellendi" token_expired_title = "Claude Code Kimlik Doğrulama Hatası" token_expired_body = "Bir terminalde 'claude' komutunu çalıştırın, '/login' komutunu kullanın ve yönergeleri izleyin. Ardından bu uygulamayı yenileyin veya yeniden başlatın." codex_token_expired_title = "Codex Kimlik Doğrulama Hatası" @@ -457,3 +462,13 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "Üzerine gelinene kadar gizle" +"Keep edge-docked widgets tucked away until you point at them" = "Kenara yerleştirilmiş widget'ları işaretçiyle üzerine gelene kadar gizli tut" +"Widget display" = "Widget ekranı" +"Monitor used by edge-docked widgets" = "Kenara yerleştirilmiş widget'ların kullandığı monitör" +"Countdown" = "Geri sayım" +"Alarm at 5% remaining" = "%5 kaldığında alarm" +"Use theme setting" = "Tema ayarını kullan" +"Overridden in Settings" = "Ayarlarda geçersiz kılındı" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "Bir sınır neredeyse dolduğunda bir kez ses çal ve kenara yerleştirilmiş widget'ları açık tut" +"Snooze alarm (1 hour)" = "Alarmı ertele (1 saat)" diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index 82c94804..1287302c 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -47,6 +47,11 @@ day_suffix = "天" hour_suffix = "时" minute_suffix = "分" second_suffix = "秒" +status_waiting = "等待使用数据" +status_live = "实时" +status_stale = "已过期" +status_live_updated = "实时 · {time}前更新" +status_stale_updated = "已过期 · {time}前更新" token_expired_title = "验证错误" token_expired_body = "请在终端中运行 'claude',然后使用 '/login' 并按照提示操作。完成后,请刷新或重新启动此应用程序。" codex_token_expired_title = "Codex 验证错误" @@ -459,3 +464,13 @@ copilot_window_title = "Copilot 使用量监控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "悬停前隐藏" +"Keep edge-docked widgets tucked away until you point at them" = "将停靠在屏幕边缘的小组件隐藏,直到指针指向它们" +"Widget display" = "小组件显示器" +"Monitor used by edge-docked widgets" = "停靠在边缘的小组件所用的显示器" +"Countdown" = "倒计时" +"Alarm at 5% remaining" = "剩余 5% 时提醒" +"Use theme setting" = "使用主题设置" +"Overridden in Settings" = "已在设置中覆盖" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "限额即将用尽时响一次提示音,并让停靠在边缘的小组件保持展开" +"Snooze alarm (1 hour)" = "暂停提醒(1 小时)" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index b0644822..13c75dc5 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -47,6 +47,11 @@ day_suffix = "天" hour_suffix = "時" minute_suffix = "分" second_suffix = "秒" +status_waiting = "等待使用資料" +status_live = "即時" +status_stale = "已過期" +status_live_updated = "即時 · {time}前更新" +status_stale_updated = "已過期 · {time}前更新" token_expired_title = "驗證錯誤" token_expired_body = "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。" codex_token_expired_title = "Codex 驗證錯誤" @@ -459,3 +464,13 @@ copilot_window_title = "Copilot 使用量監控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Hide until hover" = "懸停前隱藏" +"Keep edge-docked widgets tucked away until you point at them" = "將停靠在螢幕邊緣的小工具隱藏,直到指標指向它們" +"Widget display" = "小工具顯示器" +"Monitor used by edge-docked widgets" = "停靠在邊緣的小工具所使用的顯示器" +"Countdown" = "倒數計時" +"Alarm at 5% remaining" = "剩餘 5% 時提醒" +"Use theme setting" = "使用主題設定" +"Overridden in Settings" = "已在設定中覆寫" +"Sound once and keep edge-docked widgets open while a limit is almost spent" = "限額即將用盡時響一次提示音,並讓停靠在邊緣的小工具保持展開" +"Snooze alarm (1 hour)" = "暫停提醒(1 小時)" diff --git a/src/localization/mod.rs b/src/localization/mod.rs index bd23eab4..d81fa50c 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -174,6 +174,11 @@ pub struct Strings { pub hour_suffix: &'static str, pub minute_suffix: &'static str, pub second_suffix: &'static str, + pub status_waiting: &'static str, + pub status_live: &'static str, + pub status_stale: &'static str, + pub status_live_updated: &'static str, + pub status_stale_updated: &'static str, pub token_expired_title: &'static str, pub token_expired_body: &'static str, pub codex_token_expired_title: &'static str, diff --git a/src/models.rs b/src/models.rs index 3e11d9a7..ca4eb1de 100644 --- a/src/models.rs +++ b/src/models.rs @@ -189,6 +189,11 @@ impl AppUsageData { .map(|(provider, usage)| (*provider, usage)) } + /// At least one reading was fetched this poll rather than carried forward. + pub fn has_fresh_reading(&self) -> bool { + self.all_usage().any(|usage| !usage.stale) + } + pub fn all_usage(&self) -> impl Iterator { self.providers.values().chain( self.accounts diff --git a/src/native_interop.rs b/src/native_interop.rs index 4d66c051..9094573f 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -29,6 +29,8 @@ pub const TIMER_MOUSE_CLICK: usize = 6; pub const TIMER_TRAY_HOVER: usize = 7; pub const TIMER_CLOCK: usize = 8; pub const TIMER_TRAY_REPOSITION: usize = 9; +pub const TIMER_AUTO_HIDE: usize = 10; +pub const TIMER_ALARM_SNOOZE: usize = 11; // Custom messages pub const WM_APP: u32 = 0x8000; @@ -94,6 +96,17 @@ pub struct DesktopHost { pub insert_after: HWND, } +/// The display a surface placed on `index` really lands on: the first one when +/// that display is no longer connected. The runtime and the settings screen +/// share this, so the screen shows where the widget actually is. +pub fn display_or_first(index: usize, count: usize) -> usize { + if index < count { + index + } else { + 0 + } +} + pub fn find_monitors() -> Vec { unsafe extern "system" fn enum_proc( monitor: HMONITOR, diff --git a/src/poller/retry_after.rs b/src/poller/retry_after.rs index 80f4707c..09ea94fe 100644 --- a/src/poller/retry_after.rs +++ b/src/poller/retry_after.rs @@ -234,11 +234,16 @@ mod tests { Err(ureq::Error::StatusCode(code)) if code == status )); - // Advance the cooldown's age without sleeping or restarting. + // Advance the cooldown's age without sleeping or restarting. A + // machine booted less than a day ago cannot represent that + // instant, so expire the delay instead. { let mut cooldowns = state.cooldowns.lock().unwrap(); - cooldowns.get_mut(&key).unwrap().received = - Instant::now() - Duration::from_secs(86_400); + let cooldown = cooldowns.get_mut(&key).unwrap(); + match Instant::now().checked_sub(Duration::from_secs(86_400)) { + Some(received) => cooldown.received = received, + None => cooldown.delay = Duration::ZERO, + } } let mut sent = false; state diff --git a/src/studio_app.rs b/src/studio_app.rs index 3a9eb7ed..d9171f66 100644 --- a/src/studio_app.rs +++ b/src/studio_app.rs @@ -587,6 +587,7 @@ struct StudioApp { usage: Option, usage_poll_ok: bool, usage_has_error: bool, + usage_updated_unix: Option, last_cache_read: Instant, next_preview_countdown_refresh: Option, next_preview_clock_refresh: Option, diff --git a/src/studio_app/studio_context_menu_editor.rs b/src/studio_app/studio_context_menu_editor.rs index 0152a498..3fcec722 100644 --- a/src/studio_app/studio_context_menu_editor.rs +++ b/src/studio_app/studio_context_menu_editor.rs @@ -336,6 +336,7 @@ pub(super) fn context_menu_action_script(action: &ContextMenuAction) -> String { format!("layer_actions({})", string_arg(actions)) } ContextMenuAction::OpenUrl { url } => format!("open_url({})", string_arg(url)), + ContextMenuAction::SnoozeAlarm => "snooze_alarm()".into(), ContextMenuAction::Exit => "exit()".into(), } } @@ -352,6 +353,7 @@ pub(super) fn parse_context_menu_action_script(script: &str) -> Result ThemeRuntime { @@ -105,6 +111,9 @@ impl StudioApp { .iter() .any(|account| account.error.is_some())) }); + let usage_updated_unix = usage_cache + .as_ref() + .and_then(UsageCache::fresh_or_updated_unix); let usage = usage_cache.map(|cache| cache.data); let next_preview_countdown_refresh = preview_countdown_refresh_delay(usage.as_ref()) .and_then(|delay| Instant::now().checked_add(delay)); @@ -132,6 +141,7 @@ impl StudioApp { usage, usage_poll_ok, usage_has_error, + usage_updated_unix, last_cache_read: Instant::now(), next_preview_countdown_refresh, next_preview_clock_refresh, @@ -803,7 +813,8 @@ impl StudioApp { .any(|account| account.error.is_some())); let changed = self.usage.as_ref() != Some(&cache.data) || self.usage_poll_ok != poll_ok - || self.usage_has_error != has_error; + || self.usage_has_error != has_error + || self.usage_updated_unix != cache.fresh_or_updated_unix(); if changed { crate::diagnose::log_lazy(|| { format!( @@ -812,6 +823,7 @@ impl StudioApp { cache.data.accounts.len() ) }); + self.usage_updated_unix = cache.fresh_or_updated_unix(); self.usage = Some(cache.data); self.usage_poll_ok = poll_ok; self.usage_has_error = has_error; diff --git a/src/studio_app/studio_helper.rs b/src/studio_app/studio_helper.rs index 52941113..ca057343 100644 --- a/src/studio_app/studio_helper.rs +++ b/src/studio_app/studio_helper.rs @@ -42,6 +42,7 @@ const VALUE_CATEGORIES: &[&str] = &[ /// values can use. const MOUSE_ACTION_CATEGORIES: &[&str] = &[ DASHBOARD, + APP, LINKS_AND_MENUS, LAYERS, USAGE, @@ -148,6 +149,13 @@ const MOUSE_ACTIONS: &[ActionSpec] = &[ "toggle_dashboard()", "toggle_dashboard", ), + ( + "snooze_alarm", + APP, + "Snooze alarm (1 hour)", + "snooze_alarm()", + "snooze_alarm", + ), ( "open_url", LINKS_AND_MENUS, @@ -236,6 +244,13 @@ const MENU_ACTIONS: &[ActionSpec] = &[ "check_for_updates()", "check_for_updates", ), + ( + "snooze_alarm", + APP, + "Snooze alarm (1 hour)", + "snooze_alarm()", + "snooze_alarm", + ), ("exit", APP, "Exit", "exit()", "exit"), ( "set_update_frequency", @@ -911,6 +926,17 @@ pub(super) fn value_entries( ("i18n.hour_suffix", "Hour suffix"), ("i18n.minute_suffix", "Minute suffix"), ("i18n.second_suffix", "Second suffix"), + ("i18n.status_waiting", "Waiting for data status"), + ("i18n.status_live", "Live status"), + ("i18n.status_stale", "Stale status"), + ( + "i18n.status_live_updated", + "Live status with the age of the data", + ), + ( + "i18n.status_stale_updated", + "Stale status with the age of the data", + ), ("i18n.locale", "Language code"), ] { list.value(LABELS, None, labels, label, name); @@ -1315,6 +1341,7 @@ fn mouse_action_details( ); "toggle_dashboard()".to_string() } + "snooze_alarm" => "snooze_alarm()".to_string(), "open_url" => format!("open_url({})", quoted(&url_form(ui, forms, language)?)), "show_context_menu" => { note(ui, language.text("Shows a context menu at the pointer.")); @@ -1371,6 +1398,7 @@ fn menu_action_details( "toggle_startup" => ContextMenuAction::ToggleStartup, "toggle_taskbar_lock" => ContextMenuAction::ToggleTaskbarLock, "check_for_updates" => ContextMenuAction::CheckForUpdates, + "snooze_alarm" => ContextMenuAction::SnoozeAlarm, "exit" => ContextMenuAction::Exit, "set_update_frequency" => { form_label(ui, language.text("Update frequency")); diff --git a/src/studio_app/studio_helper_panels.rs b/src/studio_app/studio_helper_panels.rs index a105e2f8..d0920f06 100644 --- a/src/studio_app/studio_helper_panels.rs +++ b/src/studio_app/studio_helper_panels.rs @@ -9,6 +9,7 @@ pub(super) enum TextTemplateFormat { Percentage, ShortDuration, DetailedDuration, + Countdown, UsageLine, UsageBadge, WeekdayTwo, @@ -791,6 +792,7 @@ pub(super) fn text_template_formats(kind: TextTemplateValueKind) -> &'static [Te TextTemplateValueKind::Duration => &[ Format::ShortDuration, Format::DetailedDuration, + Format::Countdown, Format::WholeNumber, ], TextTemplateValueKind::Timestamp => &[ @@ -841,6 +843,7 @@ pub(super) fn text_template_format_label( TextTemplateFormat::Percentage => language.text("Percentage"), TextTemplateFormat::ShortDuration => language.text("Short duration"), TextTemplateFormat::DetailedDuration => language.text("Detailed duration"), + TextTemplateFormat::Countdown => language.text("Countdown"), TextTemplateFormat::UsageLine => language.text("Usage and reset"), TextTemplateFormat::UsageBadge => language.text("Usage only"), TextTemplateFormat::WeekdayTwo => language.text("Weekday (2 letters)"), @@ -880,6 +883,7 @@ pub(super) fn text_template_format_code(format: TextTemplateFormat) -> Option<&' TextTemplateFormat::Percentage => Some("percent"), TextTemplateFormat::ShortDuration => Some("duration_short"), TextTemplateFormat::DetailedDuration => Some("duration"), + TextTemplateFormat::Countdown => Some("countdown"), TextTemplateFormat::UsageLine => Some("usage_line"), TextTemplateFormat::UsageBadge => Some("usage_badge"), TextTemplateFormat::WeekdayTwo => Some("weekday_2"), diff --git a/src/studio_app/studio_inspectors.rs b/src/studio_app/studio_inspectors.rs index 7d9f6a88..086af892 100644 --- a/src/studio_app/studio_inspectors.rs +++ b/src/studio_app/studio_inspectors.rs @@ -406,10 +406,16 @@ pub(super) fn segment_count_expression_control( edit_clicked } -pub(super) fn paint_control(ui: &mut egui::Ui, label: &str, paint: &mut Paint) { +pub(super) fn paint_control( + ui: &mut egui::Ui, + label: &str, + paint: &mut Paint, + context: &DataContext, +) { labeled(ui, label, |ui| { let width = inspector_control_width(ui); - crate::ui::components::color_picker::color_string_field(ui, &mut paint.color, width); + let shown = display_color(&paint.color, context).map(|c| [c.r, c.g, c.b, c.a]); + crate::ui::components::color_picker::color_string_field(ui, &mut paint.color, width, shown); }); } @@ -628,10 +634,12 @@ pub(super) fn appearance_inspector( } match &mut object.background { LayerBackground::None => {} - LayerBackground::Colour { colour } => paint_control(ui, language.text("Colour"), colour), + LayerBackground::Colour { colour } => { + paint_control(ui, language.text("Colour"), colour, context) + } LayerBackground::Gradient { start, end, angle } => { - paint_control(ui, language.text("Start"), start); - paint_control(ui, language.text("End"), end); + paint_control(ui, language.text("Start"), start, context); + paint_control(ui, language.text("End"), end, context); if numeric_expression_control( ui, id.with("background-gradient-angle"), @@ -693,7 +701,12 @@ pub(super) fn appearance_inspector( }); } if let Some(border) = &mut object.border { - paint_control(ui, language.text("Border colour"), &mut border.color); + paint_control( + ui, + language.text("Border colour"), + &mut border.color, + context, + ); if numeric_expression_control( ui, id.with("border-width"), @@ -987,7 +1000,7 @@ pub(super) fn content_inspector( } }); }); - paint_control(ui, language.text("Colour"), color); + paint_control(ui, language.text("Colour"), color, context); } SceneContent::Progress { value, @@ -1031,8 +1044,8 @@ pub(super) fn content_inspector( } }); }); - paint_control(ui, language.text("Fill"), fill); - paint_control(ui, language.text("Track"), track); + paint_control(ui, language.text("Fill"), fill, context); + paint_control(ui, language.text("Track"), track, context); if numeric_expression_control( ui, id.with("radius"), diff --git a/src/studio_app/studio_scene_helpers.rs b/src/studio_app/studio_scene_helpers.rs index 29d25469..eb041ff3 100644 --- a/src/studio_app/studio_scene_helpers.rs +++ b/src/studio_app/studio_scene_helpers.rs @@ -1,5 +1,6 @@ use super::*; +#[allow(clippy::too_many_arguments)] pub(super) fn scene_row_contents( ui: &mut egui::Ui, object: &mut SceneObject, @@ -8,18 +9,19 @@ pub(super) fn scene_row_contents( expanded: Option, editable: bool, language: LanguageId, + context: &dyn Fn() -> DataContext, ) -> SceneRowResponses { ui.horizontal(|ui| { let (leading_item, expand_button) = if let Some(expanded) = expanded { let expand_button = scene_tree_chevron(ui, expanded) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(language.text("Expand or collapse")); - let preview = scene_object_preview(ui, object, preview_size) + let preview = scene_object_preview(ui, object, preview_size, context) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(language.text("Select object")); (expand_button.clone() | preview, expand_button) } else { - let preview = scene_object_preview(ui, object, preview_size) + let preview = scene_object_preview(ui, object, preview_size, context) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(language.text("Select object")); (preview.clone(), preview) @@ -101,6 +103,7 @@ pub(super) fn scene_object_preview( ui: &mut egui::Ui, object: &SceneObject, source_size: egui::Vec2, + context: &dyn Fn() -> DataContext, ) -> egui::Response { let (allocated_rect, response) = ui.allocate_exact_size(egui::vec2(27.0, 24.0), egui::Sense::click()); @@ -123,7 +126,7 @@ pub(super) fn scene_object_preview( ); let background = match &object.background { LayerBackground::Colour { colour } => { - let colour = scene_paint_color(colour); + let colour = scene_paint_color(colour, context); ui.painter().add(egui::Shape::convex_polygon( outline.clone(), colour, @@ -132,8 +135,8 @@ pub(super) fn scene_object_preview( colour } LayerBackground::Gradient { start, end, angle } => { - let start = scene_paint_color(start); - let end = scene_paint_color(end); + let start = scene_paint_color(start, context); + let end = scene_paint_color(end, context); let angle = angle.0.trim().parse::().unwrap_or_default(); paint_scene_preview_gradient(ui, preview_bounds, &outline, start, end, angle); lerp_scene_color(start, end, 0.5) @@ -147,7 +150,7 @@ pub(super) fn scene_object_preview( let width = width.clamp(0.5, 3.0); ui.painter().add(egui::Shape::closed_line( outline, - egui::Stroke::new(width, scene_paint_color(&border.color)), + egui::Stroke::new(width, scene_paint_color(&border.color, context)), )); } @@ -157,7 +160,7 @@ pub(super) fn scene_object_preview( egui::Align2::CENTER_CENTER, icon.unicode().to_string(), egui::FontId::new(16.0, egui::FontFamily::Name("lucide".into())), - scene_object_icon_color(object, background), + scene_object_icon_color(object, background, context), ); response } @@ -309,15 +312,28 @@ pub(super) fn scene_object_icon(object: &SceneObject) -> LucideIcon { pub(super) fn scene_object_icon_color( object: &SceneObject, background: egui::Color32, + context: &dyn Fn() -> DataContext, ) -> egui::Color32 { match &object.content { - SceneContent::Text { color, .. } => scene_paint_color(color), + SceneContent::Text { color, .. } => scene_paint_color(color, context), SceneContent::None | SceneContent::Progress { .. } => scene_icon_contrast_color(background), } } -pub(super) fn scene_paint_color(paint: &Paint) -> egui::Color32 { - let Some(color) = theme_engine::parse_color(&paint.color) else { +/// A colour as the preview shows it: a literal, or an expression such as +/// `if(system.dark, "#19191C", "#FFFFFF")` evaluated with the preview data. +/// The stored text is never rewritten. +pub(super) fn display_color(color: &str, context: &DataContext) -> Option { + theme_engine::parse_color(color).or_else(|| theme_engine::evaluate_color(color, context).ok()) +} + +/// `context` is built only when a colour or opacity is not a plain literal. +pub(super) fn scene_paint_color(paint: &Paint, context: &dyn Fn() -> DataContext) -> egui::Color32 { + let built = std::cell::OnceCell::new(); + let context = || built.get_or_init(context); + let color = theme_engine::parse_color(&paint.color) + .or_else(|| theme_engine::evaluate_color(&paint.color, context()).ok()); + let Some(color) = color else { return egui::Color32::TRANSPARENT; }; let opacity = paint @@ -325,6 +341,12 @@ pub(super) fn scene_paint_color(paint: &Paint) -> egui::Color32 { .0 .trim() .parse::() + .ok() + .or_else(|| { + theme_engine::evaluate(&paint.opacity.0, context()) + .ok() + .map(|value| value as f32) + }) .unwrap_or(1.0) .clamp(0.0, 1.0); egui::Color32::from_rgba_unmultiplied( diff --git a/src/studio_app/studio_settings.rs b/src/studio_app/studio_settings.rs index 030046c6..61fd2c61 100644 --- a/src/studio_app/studio_settings.rs +++ b/src/studio_app/studio_settings.rs @@ -123,6 +123,17 @@ impl StudioApp { .changed(); }, ); + match self + .theme + .edge_surface() + .map(|surface| surface.placement.clone()) + { + Some(placement) => { + changed |= edge_settings(ui, &mut self.settings, &placement, language); + } + // The alarm's sound works with every theme. + None => changed |= alarm_setting(ui, &mut self.settings, language), + } setting_separator(ui); setting_row( ui, @@ -207,6 +218,110 @@ impl StudioApp { } } +/// The choices for "Hide until hover": the theme's own setting, or forced on or off. +const HIDE_CHOICES: [Option; 3] = [None, Some(true), Some(false)]; + +/// Record the Dashboard's hiding choice. `None` hands the decision back to the theme. +fn choose_hide(settings: &mut SettingsFile, choice: Option) -> bool { + let changed = settings.hide_until_hover != choice; + settings.hide_until_hover = choice; + changed +} + +/// Give hiding back to the theme, as the Studio's "Use theme setting" does. +pub(super) fn clear_hide_override(settings: &mut SettingsFile) -> bool { + choose_hide(settings, None) +} + +/// The display the widget really sits on, which is what the selector shows. +fn shown_display(saved: Option, authored: usize, count: usize) -> usize { + native_interop::display_or_first(saved.unwrap_or(authored), count) +} + +/// An explicit display choice is always saved, even when it equals what the +/// selector already showed for a display that has gone. +fn choose_display(settings: &mut SettingsFile, index: usize) -> bool { + settings.edge_display = Some(index); + true +} + +/// Hiding and monitor choice for the active theme's edge-docked surfaces. +/// Settings hold them because built-in themes are read-only. +fn edge_settings( + ui: &mut egui::Ui, + settings: &mut SettingsFile, + placement: &Placement, + language: LanguageId, +) -> bool { + let mut changed = false; + setting_separator(ui); + setting_row( + ui, + language.text("Hide until hover"), + language.text("Keep edge-docked widgets tucked away until you point at them"), + |ui| { + let name = |choice: Option| match choice { + None => language.text("Use theme setting"), + Some(true) => language.text("Enabled"), + Some(false) => language.text("Disabled"), + }; + Dropdown::from_id_salt("hide_until_hover") + .width(220.0) + .selected_text(name(settings.hide_until_hover)) + .show_ui(ui, |ui| { + for choice in HIDE_CHOICES { + let selected = settings.hide_until_hover == choice; + if dropdown_selectable_label(ui, selected, name(choice)).clicked() { + changed |= choose_hide(settings, choice); + } + } + }); + }, + ); + changed |= alarm_setting(ui, settings, language); + setting_separator(ui); + setting_row( + ui, + language.text("Widget display"), + language.text("Monitor used by edge-docked widgets"), + |ui| { + let count = native_interop::find_monitors().len().max(1); + let name = |index: usize| format!("{} {}", language.text("Display"), index + 1); + let display = shown_display(settings.edge_display, placement.reference.display, count); + Dropdown::from_id_salt("edge_display") + .width(220.0) + .selected_text(name(display)) + .show_ui(ui, |ui| { + for index in 0..count { + // Clicking the value on show still saves it, so a + // saved display that is gone can be settled. + if dropdown_selectable_label(ui, display == index, name(index)).clicked() { + changed |= choose_display(settings, index); + } + } + }); + }, + ); + changed +} + +fn alarm_setting(ui: &mut egui::Ui, settings: &mut SettingsFile, language: LanguageId) -> bool { + setting_separator(ui); + let mut changed = false; + setting_row( + ui, + language.text("Alarm at 5% remaining"), + language.text("Sound once and keep edge-docked widgets open while a limit is almost spent"), + |ui| { + changed = Toggle::new(&mut settings.low_usage_alarm) + .labels(language.text("Enabled"), language.text("Disabled")) + .show(ui) + .changed(); + }, + ); + changed +} + fn account_settings( ui: &mut egui::Ui, provider: ProviderId, @@ -339,3 +454,50 @@ mod account_status_tests { ); } } + +#[cfg(test)] +mod edge_setting_tests { + use super::*; + + #[test] + fn the_dashboard_can_hand_hiding_back_to_the_theme_and_the_theme_wins_again() { + let mut theme: crate::theme_engine::ThemeDocument = + serde_json::from_str(include_str!("../themes/top-bar.json")).unwrap(); + theme.prepare_runtime(); + let authored = theme.surfaces[0].placement.auto_hide; + assert!(authored, "the Top Bar hides by itself"); + let mut settings = SettingsFile::default(); + let effective = |settings: &SettingsFile| { + let mut theme = theme.clone(); + theme.apply_edge_preferences(settings.hide_until_hover, None); + theme.surfaces[0].placement.auto_hide + }; + assert_eq!(effective(&settings), authored); + // Forced off, then forced on, then back to the theme's own. + assert!(choose_hide(&mut settings, Some(false))); + assert!(!effective(&settings)); + assert!(!choose_hide(&mut settings, Some(false)), "nothing to save"); + assert!(choose_hide(&mut settings, Some(true))); + assert!(effective(&settings)); + assert!(choose_hide(&mut settings, Some(false))); + assert!(clear_hide_override(&mut settings)); + assert_eq!(settings.hide_until_hover, None); + assert_eq!(effective(&settings), authored); + assert!(!clear_hide_override(&mut settings), "already the theme's"); + } + + #[test] + fn a_saved_display_that_is_gone_shows_where_the_widget_is_and_a_click_settles_it() { + // Two displays connected; the saved third is gone. The runtime puts the + // widget on the first, and so does the selector. + assert_eq!(shown_display(Some(2), 0, 2), 0); + assert_eq!(shown_display(None, 3, 2), 0); + assert_eq!(shown_display(Some(1), 0, 2), 1); + assert_eq!(shown_display(None, 0, 1), 0); + let mut settings = SettingsFile::default(); + settings.edge_display = Some(2); + // Clicking the display it already shows still saves it. + assert!(choose_display(&mut settings, 0)); + assert_eq!(settings.edge_display, Some(0)); + } +} diff --git a/src/studio_app/studio_theme_workspace.rs b/src/studio_app/studio_theme_workspace.rs index 83579a98..87e1cd23 100644 --- a/src/studio_app/studio_theme_workspace.rs +++ b/src/studio_app/studio_theme_workspace.rs @@ -525,6 +525,11 @@ impl StudioApp { context } + /// Preview data for colour expressions in thumbnails and pickers. + pub(super) fn color_preview_context(&self, surface_index: usize) -> DataContext { + self.expression_context(Selection::Surface(surface_index)) + } + pub(super) fn scene_tree(&mut self, ui: &mut egui::Ui, read_only: bool) { let language = self.language(); self.hovered_scene_item = None; @@ -592,6 +597,7 @@ impl StudioApp { None, !read_only, language, + &|| self.color_preview_context(surface_index), ) }); if responses.name_changed { @@ -630,6 +636,7 @@ impl StudioApp { Some(is_open), !read_only, language, + &|| self.color_preview_context(surface_index), ) }); if responses.name_changed { @@ -787,6 +794,7 @@ impl StudioApp { Some(is_open), !read_only, language, + &|| self.color_preview_context(surface), ) }); let selection = Selection::Object(surface, index); @@ -841,6 +849,7 @@ impl StudioApp { None, !read_only, language, + &|| self.color_preview_context(surface), ) }); if responses.name_changed { @@ -1306,6 +1315,8 @@ impl StudioApp { ui.make_persistent_id(("root-object-controls", surface.id.clone())); let placement_controls_id = ui.make_persistent_id(("placement-expression-controls", surface.id.clone())); + let hide_override = self.settings.hide_until_hover; + let mut clear_override = false; let mut requested_expression = None; let mut requested_text_template = false; let mut requested_asset = false; @@ -1462,6 +1473,28 @@ impl StudioApp { }); surface.placement.surface_horizontal = Some(surface_horizontal); surface.placement.surface_vertical = Some(surface_vertical); + // Only floating roots docked to a top or bottom edge can hide. + if surface.placement.edge().is_some() { + toggle_labeled_control( + ui, + language.text("Hide until hover"), + &mut surface.placement.auto_hide, + language, + ); + // The Dashboard's choice wins over this toggle until cleared. + if let Some(forced) = hide_override { + ui.horizontal_wrapped(|ui| { + ui.weak(format!( + "{}: {}", + language.text("Overridden in Settings"), + language.text(if forced { "Enabled" } else { "Disabled" }) + )); + if ui.button(language.text("Use theme setting")).clicked() { + clear_override = true; + } + }); + } + } for (label, field, value) in [ ( language.text("Height"), @@ -1565,6 +1598,9 @@ impl StudioApp { if requested_asset { self.open_asset_picker(Selection::Surface(index), &surface.background); } + if clear_override && studio_settings::clear_hide_override(&mut self.settings) { + self.save_settings(); + } let changed = before != serde_json::to_string(&surface).unwrap_or_default(); if changed { self.theme.surfaces[index] = surface; diff --git a/src/studio_app/tests.rs b/src/studio_app/tests.rs index 65bf6d9a..471189e0 100644 --- a/src/studio_app/tests.rs +++ b/src/studio_app/tests.rs @@ -451,6 +451,7 @@ fn app_with_surfaces(surfaces: Vec) -> StudioApp { usage: None, usage_poll_ok: false, usage_has_error: false, + usage_updated_unix: None, last_cache_read: Instant::now(), next_preview_countdown_refresh: None, next_preview_clock_refresh: None, @@ -688,6 +689,22 @@ fn unchanged_usage_cache_does_not_invalidate_the_preview() { assert!(!app.preview_dirty); } +#[test] +fn dashboard_fresh_time_follows_the_cache_field() { + let mut app = app_with_surfaces(vec![root("main")]); + let cache = |fresh_unix| UsageCache { + updated_unix: 50, + fresh_unix, + ..Default::default() + }; + app.update_usage_cache(cache(None)); + assert_eq!(app.usage_updated_unix, Some(50)); + app.update_usage_cache(cache(Some(Some(30)))); + assert_eq!(app.usage_updated_unix, Some(30)); + app.update_usage_cache(cache(Some(None))); + assert_eq!(app.usage_updated_unix, None); +} + #[test] fn preview_countdowns_refresh_on_minute_boundaries_until_the_final_minute() { assert_eq!( @@ -783,6 +800,7 @@ fn context_menu_action_scripts_round_trip_every_action_kind() { ContextMenuAction::OpenUrl { url: "https://example.com/a?q=1".into(), }, + ContextMenuAction::SnoozeAlarm, ContextMenuAction::Exit, ]; for action in actions { @@ -1525,14 +1543,15 @@ fn duplicate_and_delete_include_the_selected_subtree() { fn scene_preview_colors_preserve_alpha_and_choose_readable_icons() { let mut light = Paint::new("#FFFFFFFF"); light.opacity = 0.5.into(); - let light = scene_paint_color(&light); + let none = || DataContext::from_usage(None, &Canvas::default()); + let light = scene_paint_color(&light, &none); assert_eq!(light.a(), 128); assert_eq!( scene_icon_contrast_color(egui::Color32::WHITE), egui::Color32::BLACK ); - let dark = scene_paint_color(&Paint::new("#101214FF")); + let dark = scene_paint_color(&Paint::new("#101214FF"), &none); assert_eq!(scene_icon_contrast_color(dark), egui::Color32::WHITE); assert_eq!( scene_icon_contrast_color(egui::Color32::TRANSPARENT), @@ -1567,11 +1586,159 @@ fn scene_preview_colors_preserve_alpha_and_choose_readable_icons() { color: text_paint.clone(), }; assert_eq!( - scene_object_icon_color(&layout_object, egui::Color32::WHITE), - scene_paint_color(&text_paint) + scene_object_icon_color(&layout_object, egui::Color32::WHITE, &none), + scene_paint_color(&text_paint, &none) + ); +} + +#[test] +fn colour_expressions_display_their_evaluated_colour_and_stay_unchanged() { + let expression = r##"if(system.dark, "#102030", "#F0E0D0")"##; + let paint = Paint::new(expression); + let context = |dark| { + move || { + DataContext::from_usage_with_runtime( + None, + &Canvas::default(), + ThemeRuntime::default().with_system_dark(dark), + ) + } + }; + let dark = scene_paint_color(&paint, &context(true)); + let light = scene_paint_color(&paint, &context(false)); + assert_eq!(dark, egui::Color32::from_rgb(0x10, 0x20, 0x30)); + assert_eq!(light, egui::Color32::from_rgb(0xF0, 0xE0, 0xD0)); + let shown = display_color(expression, &context(true)()); + assert_eq!(shown.map(|c| (c.r, c.g, c.b)), Some((0x10, 0x20, 0x30))); + assert_eq!(paint.color, expression); + assert_eq!( + scene_paint_color(&Paint::new("not a colour"), &context(true)), + egui::Color32::TRANSPARENT + ); +} + +/// Drives the colour field the inspector uses (`color_string_field`, fed the +/// evaluated colour exactly as `paint_control` does) with synthetic input. +struct PickerHarness { + context: egui::Context, + value: String, + button: egui::Pos2, +} + +impl PickerHarness { + fn new(value: &str) -> Self { + let context = egui::Context::default(); + configure_style(&context, LanguageId::English); + let mut harness = Self { + context, + value: value.into(), + button: egui::Pos2::ZERO, + }; + harness.frame(Vec::new()); + harness + } + + fn frame(&mut self, events: Vec) { + let dark = DataContext::from_usage_with_runtime( + None, + &Canvas::default(), + ThemeRuntime::default().with_system_dark(true), + ); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 900.0), + )), + events, + ..Default::default() + }; + let (value, button) = (&mut self.value, &mut self.button); + run_test_ui(&self.context, input, |ui| { + let shown = display_color(value, &dark).map(|c| [c.r, c.g, c.b, c.a]); + let rect = + crate::ui::components::color_picker::color_string_field(ui, value, 260.0, shown) + .rect; + *button = rect.left_center() + egui::vec2(8.0, 0.0); + }); + } + + fn click(&mut self, at: egui::Pos2) { + let event = |pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + self.frame(vec![egui::Event::PointerMoved(at)]); + self.frame(vec![event(true)]); + self.frame(vec![event(false)]); + self.frame(Vec::new()); + self.frame(Vec::new()); + } + + /// The largest floating layer: the open colour popup. + fn popup(&self) -> Option { + self.context.memory(|memory| { + memory + .layer_ids() + .filter(|layer| layer.order != egui::Order::Background) + .filter_map(|layer| memory.area_rect(layer.id)) + .max_by(|a, b| a.area().total_cmp(&b.area())) + }) + } +} + +#[test] +fn the_real_picker_rewrites_an_expression_only_when_a_colour_is_chosen() { + let expression = r##"if(system.dark, "#102030", "#F0E0D0")"##; + let mut harness = PickerHarness::new(expression); + harness.click(harness.button); + let popup = harness + .popup() + .expect("clicking the swatch opens the picker"); + // Opening, hovering and idling without choosing must not touch the text. + harness.frame(vec![egui::Event::PointerMoved(popup.center())]); + harness.frame(Vec::new()); + assert_eq!(harness.value, expression); + // Choosing a colour in the picker replaces the text with that colour. + harness.click(popup.center() + egui::vec2(40.0, 20.0)); + assert_ne!(harness.value, expression); + assert!( + harness.value.starts_with('#') && harness.value.len() == 9, + "{}", + harness.value ); } +#[test] +fn colour_previews_use_the_selected_surfaces_real_canvas() { + // Default Canvas is 292 wide; this surface is 200 wide. + let expression = r##"if(canvas.width > 250, "#FF0000", "#00FF00")"##; + let app = app_with_surfaces(vec![root("alpha")]); + let context = app.color_preview_context(0); + assert_eq!(context.get("canvas.width"), Some(200.0)); + let shown = display_color(expression, &context).expect("expression evaluates"); + assert_eq!((shown.r, shown.g, shown.b), (0x00, 0xFF, 0x00)); +} + +#[test] +fn an_untouched_colour_expression_survives_a_save_and_reload() { + let expression = r##"if(system.dark, "#102030", "#F0E0D0")"##; + let mut theme = ThemeDocument::starter(); + theme.id = "expression-round-trip".into(); + theme.name = "Expression round trip".into(); + theme.surfaces[0].background = theme_engine::LayerBackground::Colour { + colour: Paint::new(expression), + }; + let path = theme_engine::save_theme(&theme).unwrap(); + let loaded = theme_engine::load_theme(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + match &loaded.surfaces[0].background { + theme_engine::LayerBackground::Colour { colour } => assert_eq!(colour.color, expression), + other => panic!("background changed on reload: {other:?}"), + } +} + #[test] fn scene_preview_stretches_the_entire_layer_background() { let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(24.0, 24.0)); diff --git a/src/theme_engine.rs b/src/theme_engine.rs index 44c2ce0a..343562b4 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -23,6 +23,7 @@ pub const CLASSIC_THEME_ID: &str = "classic-usage-widget"; pub const COMPACT_FLUENT_QUAD_THEME_ID: &str = "compact-fluent-quad"; pub const CLASSIC_VERTICAL_THEME_ID: &str = "classic-vertical"; pub const MINECRAFT_THEME_ID: &str = "theme-minecraft"; +pub const TOP_BAR_THEME_ID: &str = "top-bar"; const BUILTIN_THEME_SOURCES: &[(&str, &str)] = &[ ( @@ -37,6 +38,10 @@ const BUILTIN_THEME_SOURCES: &[(&str, &str)] = &[ CLASSIC_VERTICAL_THEME_ID, include_str!(concat!(env!("OUT_DIR"), "/classic-vertical.json")), ), + ( + TOP_BAR_THEME_ID, + include_str!(concat!(env!("OUT_DIR"), "/top-bar.json")), + ), ]; /// Bundled starting points are copied into the managed library only when they @@ -193,12 +198,54 @@ pub struct Placement { pub offset_y: i32, #[serde(default, skip_serializing_if = "Option::is_none")] pub offset_y_expression: Option, + /// Tuck an edge-docked floating surface away behind a slim handle until + /// the pointer rests on it. Ignored where `edge` is `None`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub auto_hide: bool, + /// Colour of the collapsed handle's accent, as an expression producing + /// `#RRGGBB` or `#RRGGBBAA` text, so it can warn about high usage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handle_color: Option, + /// Fill of the collapsed handle, as colour text like `handle_color`, so + /// the tab can match the surface's own card. Omitted, the tab is dark. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handle_background: Option, + /// Keep an auto-hiding surface revealed while this is non-zero, for + /// example `alarm.active` so an almost spent limit cannot slide away. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub force_reveal: Option, +} + +/// The monitor edge a floating surface is docked against. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SurfaceEdge { + Top, + Bottom, } impl Placement { fn is_default(&self) -> bool { self == &Self::default() } + + /// A floating surface whose top or bottom sits on its reference's top or + /// bottom edge. Only these can hide until hovered. + pub fn edge(&self) -> Option { + if self.nest.resolve(self.reference.region) != SurfaceNest::Floating + || self.surface_vertical.unwrap_or(self.vertical) != self.vertical + { + return None; + } + match self.vertical { + VerticalAnchor::Top => Some(SurfaceEdge::Top), + VerticalAnchor::Bottom => Some(SurfaceEdge::Bottom), + VerticalAnchor::Center => None, + } + } + + pub fn auto_hide_edge(&self) -> Option { + self.edge().filter(|_| self.auto_hide) + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -463,6 +510,8 @@ pub enum MouseActionTarget { pub enum MouseAction { ShowDashboard, ToggleDashboard, + /// What "Snooze alarm (1 hour)" does in the context menu. + SnoozeAlarm, OpenUrl { url: String, }, @@ -505,13 +554,14 @@ pub struct MouseActionOverrideKey { pub enum MouseActionEffect { ShowDashboard, ToggleDashboard, + SnoozeAlarm, OpenUrl(String), ShowContextMenu(Option), } /// Parse the deliberately small, line-or-semicolon separated action language. /// Supported forms include `show_dashboard()`, `toggle_dashboard()`, -/// `open_url("https://example.com")`, `show_context_menu()`, +/// `snooze_alarm()`, `open_url("https://example.com")`, `show_context_menu()`, /// `show_context_menu("menu-id")`, `set(self.render, false)`, /// `increase("layer-id", height, 10)`, `decrease(self.width, 5)`, /// `toggle(self.render)`, and `reset("layer-id", height)`. @@ -532,6 +582,8 @@ pub fn parse_mouse_actions(source: &str) -> Result, String> { "show_dashboard" => return Err("show_dashboard() does not take arguments".into()), "toggle_dashboard" if args.is_empty() => actions.push(MouseAction::ToggleDashboard), "toggle_dashboard" => return Err("toggle_dashboard() does not take arguments".into()), + "snooze_alarm" if args.is_empty() => actions.push(MouseAction::SnoozeAlarm), + "snooze_alarm" => return Err("snooze_alarm() does not take arguments".into()), "open_url" if args.len() == 1 => { let url = parse_quoted_action_string(&args[0], "URL")?; if !crate::context_menu::supported_url(&url) { @@ -1338,6 +1390,45 @@ pub struct ThemeRuntime { pub floating_card_opacity: u8, host_width: u32, host_height: u32, + /// Unix time of the last successful usage refresh, when known. + updated_unix: Option, + /// Windows dark mode; `None` reads the system setting. + system_dark: Option, + pub alarm: ThemeAlarm, +} + +/// What remains of a limit, in percent, at or below which it raises the +/// low-usage alarm. Compared after rounding the way usage text is shown, so a +/// limit reading "5%" left always alarms. +pub const ALARM_REMAINING_PERCENT: f64 = 5.0; + +/// Whether a limit's reading is in the low-usage alarm zone. +pub fn limit_in_alarm_zone(section: &crate::models::UsageSection) -> bool { + section.available && 100.0 - section.percentage < ALARM_REMAINING_PERCENT + 0.5 +} + +/// The real window a provider's `session` binding shows: `five_hour`, or for +/// Codex without a five-hour window, `weekly`. The renderer and the alarm both +/// resolve `session` through here. +pub(crate) fn session_window( + usage: &crate::models::UsageData, + codex_compatibility: bool, +) -> &'static str { + if codex_compatibility && !usage.session.available && usage.weekly.available { + "weekly" + } else { + "five_hour" + } +} + +/// The low-usage alarm as the app last decided it, exposed as `alarm.*`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ThemeAlarm { + /// The alarm setting is on, so almost spent limits set `.alarm`. + pub enabled: bool, + /// A shown limit is almost spent and the alarm is not snoozed. + pub active: bool, + pub snoozed: bool, } impl Default for ThemeRuntime { @@ -1352,6 +1443,9 @@ impl Default for ThemeRuntime { floating_card_opacity: 85, host_width: default_canvas_width(), host_height: default_canvas_height(), + updated_unix: None, + system_dark: None, + alarm: ThemeAlarm::default(), } } } @@ -1382,9 +1476,34 @@ impl ThemeRuntime { floating_card_opacity: 85, host_width: default_canvas_width(), host_height: default_canvas_height(), + updated_unix: None, + system_dark: None, + alarm: ThemeAlarm::default(), } } + /// Record when usage was last refreshed, exposed as `data.updated.*`. + pub fn with_updated_unix(mut self, updated_unix: Option) -> Self { + self.updated_unix = updated_unix.filter(|unix| *unix > 0); + self + } + + /// Render as if Windows were in dark or light mode. + #[cfg(test)] + pub fn with_system_dark(mut self, dark: bool) -> Self { + self.system_dark = Some(dark); + self + } + + pub fn system_dark(self) -> bool { + self.system_dark.unwrap_or_else(crate::theme::is_dark_mode) + } + + pub fn with_alarm(mut self, alarm: ThemeAlarm) -> Self { + self.alarm = alarm; + self + } + pub fn with_nest(mut self, nest: SurfaceNest) -> Self { self.surface_nest = nest; self @@ -1448,6 +1567,9 @@ impl DataContext { context.insert("canvas.height", canvas.height as f64); context.insert("parent.width", canvas.width as f64); context.insert("parent.height", canvas.height as f64); + // Layout replaces this with the real parent's gap; validation and root + // expressions see no parent spacing. + context.insert("parent.gap", 0.0); context.insert("host.width", runtime.host_width as f64); context.insert("host.height", runtime.host_height as f64); context.insert("pi", std::f64::consts::PI); @@ -1461,7 +1583,10 @@ impl DataContext { context.insert("app.version.major", version_parts.next().unwrap_or(0.0)); context.insert("app.version.minor", version_parts.next().unwrap_or(0.0)); context.insert("app.version.patch", version_parts.next().unwrap_or(0.0)); - context.insert("system.dark", crate::theme::is_dark_mode() as u8 as f64); + context.insert("system.dark", runtime.system_dark() as u8 as f64); + context.insert("alarm.enabled", runtime.alarm.enabled as u8 as f64); + context.insert("alarm.active", runtime.alarm.active as u8 as f64); + context.insert("alarm.snoozed", runtime.alarm.snoozed as u8 as f64); context.insert("data.poll_ok", runtime.poll_ok as u8 as f64); context.insert("data.has_error", runtime.has_error as u8 as f64); context.insert( @@ -1479,6 +1604,9 @@ impl DataContext { context.insert_string("i18n.hour_suffix", strings.hour_suffix); context.insert_string("i18n.minute_suffix", strings.minute_suffix); context.insert_string("i18n.second_suffix", strings.second_suffix); + context.insert_string("i18n.status_waiting", strings.status_waiting); + context.insert_string("i18n.status_live", strings.status_live); + context.insert_string("i18n.status_stale", strings.status_stale); context.insert("providers.count", runtime.provider_count() as f64); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1486,6 +1614,24 @@ impl DataContext { .unwrap_or(0.0); context.insert("time.now.unix", now); context.insert("time.now.milliseconds", now * 1000.0); + let updated = runtime.updated_unix.map(|unix| unix as f64); + context.insert("data.updated.available", updated.is_some() as u8 as f64); + context.insert("data.updated.unix", updated.unwrap_or(0.0)); + context.insert( + "data.updated.seconds", + updated.map_or(0.0, |unix| (now - unix).max(0.0)), + ); + // The status lines carry the data's age in the user's word order. + let age = updated.map(|unix| format_value((now - unix).max(0.0), "countdown", &context)); + for (name, template) in [ + ("status_live_updated", strings.status_live_updated), + ("status_stale_updated", strings.status_stale_updated), + ] { + let line = age + .as_deref() + .map_or_else(String::new, |age| template.replace("{time}", age)); + context.insert_string(&format!("i18n.{name}"), line); + } for (zone, local) in [("local", true), ("utc", false)] { if let Some(value) = timestamp_parts(now, local) { for (component, value) in [ @@ -1509,6 +1655,9 @@ impl DataContext { ); } context.insert("display.countdown", runtime.countdown as u8 as f64); + // Only enabled providers can raise the alarm. + let alarm = + |provider: ProviderId| runtime.alarm.enabled && runtime.provider_enabled(provider); if let Some(data) = data { for account in &data.accounts { let key = format!( @@ -1527,6 +1676,7 @@ impl DataContext { account.usage.as_ref(), account.provider == ProviderId::Codex, runtime.countdown, + alarm(account.provider), ); } for descriptor in PROVIDER_DESCRIPTORS { @@ -1549,6 +1699,7 @@ impl DataContext { data.get(descriptor.id), descriptor.id == ProviderId::Codex, runtime.countdown, + alarm(descriptor.id), ); } let active = ProviderId::ALL @@ -1559,6 +1710,7 @@ impl DataContext { active.map(|(_, usage)| usage), active.is_some_and(|(provider, _)| provider == ProviderId::Codex), runtime.countdown, + active.is_some_and(|(provider, _)| alarm(provider)), ); } else { for descriptor in PROVIDER_DESCRIPTORS { @@ -1567,9 +1719,10 @@ impl DataContext { None, descriptor.id == ProviderId::Codex, runtime.countdown, + false, ); } - context.insert_provider("active", None, false, runtime.countdown); + context.insert_provider("active", None, false, runtime.countdown, false); } context } @@ -1580,11 +1733,12 @@ impl DataContext { usage: Option<&crate::models::UsageData>, codex_compatibility: bool, countdown: bool, + alarm: bool, ) { // What a gauge or a badge should show. `percentage` stays the share // that has been spent so warning thresholds keep working, while // `display` follows the countdown setting. - self.insert_limits(name, usage, countdown); + self.insert_limits(name, usage, countdown, alarm); let display = |percentage: f64| { if countdown { 100.0 - percentage @@ -1605,8 +1759,8 @@ impl DataContext { // response arrived in the primary slot and was exposed to themes as // `codex.session`. Keep that established binding working for existing // custom themes, while `codex.five_hour` always means the real window. - let use_codex_session_fallback = codex_compatibility - && usage.is_some_and(|usage| !usage.session.available && usage.weekly.available); + let use_codex_session_fallback = + usage.is_some_and(|usage| session_window(usage, codex_compatibility) == "weekly"); let session = if use_codex_session_fallback { weekly } else { @@ -1724,6 +1878,27 @@ impl DataContext { available as u8 as f64, ); } + // Almost spent, by the same rule that sounds the app's alarm. + let zone = |section: Option<&crate::models::UsageSection>| { + (alarm && section.is_some_and(limit_in_alarm_zone)) as u8 as f64 + }; + let five_hour_alarm = zone(usage.map(|usage| &usage.session)); + let weekly_alarm = zone(usage.map(|usage| &usage.weekly)); + for (window, value) in [ + ( + "session", + if use_codex_session_fallback { + weekly_alarm + } else { + five_hour_alarm + }, + ), + ("five_hour", five_hour_alarm), + ("weekly", weekly_alarm), + ("monthly", zone(monthly)), + ] { + self.insert(&format!("{name}.{window}.alarm"), value); + } let (monthly_unix, monthly_seconds) = reset_value(monthly.and_then(|value| value.resets_at)); for (window, unix, seconds) in [ @@ -1814,7 +1989,7 @@ impl DataContext { static DEFAULTS: OnceLock = OnceLock::new(); DEFAULTS.get_or_init(|| { let mut context = Self::default(); - context.insert_provider("account", None, false, false); + context.insert_provider("account", None, false, false, false); context.insert_string("account.name", ""); context.insert("account.selected", 0.0); context.insert("account.has_error", 0.0); @@ -1946,6 +2121,7 @@ pub fn validate_mouse_action_script( let (target, property, value) = match action { MouseAction::ShowDashboard | MouseAction::ToggleDashboard + | MouseAction::SnoozeAlarm | MouseAction::OpenUrl { .. } | MouseAction::ShowContextMenu { .. } => continue, MouseAction::Set { @@ -2152,6 +2328,7 @@ pub fn execute_mouse_actions( match action { MouseAction::ShowDashboard => effects.push(MouseActionEffect::ShowDashboard), MouseAction::ToggleDashboard => effects.push(MouseActionEffect::ToggleDashboard), + MouseAction::SnoozeAlarm => effects.push(MouseActionEffect::SnoozeAlarm), MouseAction::OpenUrl { url } => effects.push(MouseActionEffect::OpenUrl(url)), MouseAction::ShowContextMenu { menu } => { effects.push(MouseActionEffect::ShowContextMenu(menu)) @@ -2249,6 +2426,37 @@ impl ThemeDocument { is_builtin_theme_id(&self.id) } + /// The first surface docked against a monitor edge, which the display + /// settings for hiding and monitor choice describe. + pub fn edge_surface(&self) -> Option<&SceneObject> { + self.surfaces + .iter() + .find(|surface| surface.placement.edge().is_some()) + } + + /// Apply the user's settings for edge-docked surfaces. Built-in themes are + /// read-only, so hiding and the chosen display live in settings. + pub fn apply_edge_preferences( + &mut self, + hide_until_hover: Option, + display: Option, + ) { + for surface in &mut self.surfaces { + if surface.placement.edge().is_none() { + continue; + } + if let Some(hide) = hide_until_hover { + surface.placement.auto_hide = hide; + } + if let Some(display) = display { + surface.placement.reference.display = display; + } + } + if let Some(surface) = self.surfaces.first() { + self.placement = surface.placement.clone(); + } + } + pub fn is_builtin_classic(&self) -> bool { self.id == CLASSIC_THEME_ID } @@ -2373,6 +2581,24 @@ impl ThemeDocument { let context = DataContext::from_usage(None, &canvas); validate_scene_object(&mut errors, &context, root); validate_scene_mouse_events(&mut errors, &context, self, surface_index, root); + for (name, expression) in [ + ("handle_color", &root.placement.handle_color), + ("handle_background", &root.placement.handle_background), + ] { + if let Some(Err(error)) = + expression.as_ref().map(|e| evaluate_color(&e.0, &context)) + { + errors.push(format!("{}.placement.{name}: {error}", root.name)); + } + } + if let Some(expression) = &root.placement.force_reveal { + validate_expression( + &mut errors, + &context, + &format!("{}.placement.force_reveal", root.name), + expression, + ); + } let mut ids = std::collections::HashSet::new(); ids.insert(root.id.to_ascii_lowercase()); @@ -2429,8 +2655,13 @@ impl ThemeDocument { fn current_time_refresh_interval_for(value: &impl Serialize) -> Option { let source = serde_json::to_string(value).ok()?.to_ascii_lowercase(); - let uses_clock = - source.contains("time.now") || source.contains("time.local") || source.contains("time.utc"); + // Countdowns and the age of the last refresh change every minute even + // when no new usage arrives. + let uses_clock = source.contains("time.now") + || source.contains("time.local") + || source.contains("time.utc") + || source.contains(":countdown") + || source.contains("data.updated"); if !uses_clock { return None; } @@ -2682,9 +2913,9 @@ fn validate_expression( } fn validate_paint(errors: &mut Vec, context: &DataContext, label: &str, paint: &Paint) { - if parse_color(&paint.color).is_none() { + if parse_color(&paint.color).is_none() && evaluate_color(&paint.color, context).is_err() { errors.push(format!( - "{label}: '{}' is not #RRGGBB or #AARRGGBB", + "{label}: '{}' is not #RRGGBB, #RRGGBBAA or an expression producing one", paint.color )); } @@ -2752,6 +2983,10 @@ impl Default for Placement { offset_x_expression: None, offset_y: 0, offset_y_expression: None, + auto_hide: false, + handle_color: None, + handle_background: None, + force_reveal: None, } } } @@ -2768,12 +3003,16 @@ impl Paint { } } pub fn resolve(&self, context: &DataContext) -> Rgba { - let mut rgba = parse_color(&self.color).unwrap_or(Rgba { - r: 255, - g: 0, - b: 255, - a: 255, - }); + // A colour can also be an expression producing colour text, such as + // `if(system.dark, "#19191C", "#FFFFFF")`. + let mut rgba = parse_color(&self.color) + .or_else(|| evaluate_color(&self.color, context).ok()) + .unwrap_or(Rgba { + r: 255, + g: 0, + b: 255, + a: 255, + }); let opacity = evaluate(&self.opacity.0, context) .unwrap_or(1.0) .clamp(0.0, 1.0); @@ -2794,6 +3033,7 @@ pub use theme_expression::*; mod theme_datetime; mod theme_limits; use theme_datetime::*; +pub use theme_limits::{alarm_scan, AlarmScan}; fn schema_version() -> u32 { THEME_SCHEMA_VERSION } @@ -2875,3 +3115,6 @@ fn safe_file_stem(id: &str) -> String { #[cfg(test)] mod tests; + +#[cfg(test)] +mod top_bar_tests; diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index af11fbbc..0a04a290 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -1789,10 +1789,11 @@ fn starter_tray_icons_follow_enabled_providers() { #[test] fn built_in_themes_are_valid_and_cannot_be_saved_as_editable_themes() { - assert_eq!(BUILTIN_THEME_SOURCES.len(), 3); + assert_eq!(BUILTIN_THEME_SOURCES.len(), 4); assert_eq!(BUILTIN_THEME_SOURCES[0].0, CLASSIC_THEME_ID); assert_eq!(BUILTIN_THEME_SOURCES[1].0, COMPACT_FLUENT_QUAD_THEME_ID); assert_eq!(BUILTIN_THEME_SOURCES[2].0, CLASSIC_VERTICAL_THEME_ID); + assert_eq!(BUILTIN_THEME_SOURCES[3].0, TOP_BAR_THEME_ID); assert!(REMOVED_BUILTIN_THEME_IDS .iter() .all(|id| !is_builtin_theme_id(id))); @@ -2228,6 +2229,13 @@ fn mouse_action_parser_preserves_nested_value_expressions() { assert!(parse_mouse_actions("open_url(\"file:///temp\")") .unwrap_err() .contains("http:// or https://")); + assert_eq!( + parse_mouse_actions("snooze_alarm()").unwrap(), + [MouseAction::SnoozeAlarm] + ); + assert!(parse_mouse_actions("snooze_alarm(60)") + .unwrap_err() + .contains("does not take arguments")); } #[test] @@ -2369,7 +2377,7 @@ fn dashboard_and_context_menu_actions_emit_ordered_effects() { &theme, 0, &self_id, - "show_dashboard(); toggle_dashboard(); open_url(\"https://example.com/usage\"); show_context_menu()", + "show_dashboard(); toggle_dashboard(); open_url(\"https://example.com/usage\"); show_context_menu(); snooze_alarm()", None, ThemeRuntime::default(), &mut HashMap::new(), @@ -2382,6 +2390,7 @@ fn dashboard_and_context_menu_actions_emit_ordered_effects() { MouseActionEffect::ToggleDashboard, MouseActionEffect::OpenUrl("https://example.com/usage".into()), MouseActionEffect::ShowContextMenu(None), + MouseActionEffect::SnoozeAlarm, ] ); } diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index 62bf59ca..26271538 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -467,6 +467,17 @@ pub(super) fn call_function( } } +/// Evaluate an expression that must produce colour text, such as +/// `if(claude.headline.percentage >= 90, "#FF5C61", "#FFFFFFA0")`. +pub fn evaluate_color(source: &str, context: &DataContext) -> Result { + match evaluate_value(source, context)? { + ExpressionValue::Text(text) => { + parse_color(&text).ok_or_else(|| format!("'{text}' is not #RRGGBB or #RRGGBBAA")) + } + ExpressionValue::Number(_) => Err("Expected colour text, found a number".into()), + } +} + pub fn parse_color(source: &str) -> Option { let hex = source.trim().strip_prefix('#')?; let pair = |start| u8::from_str_radix(&hex[start..start + 2], 16).ok(); @@ -628,6 +639,28 @@ pub(super) fn format_value(value: f64, format: &str, context: &DataContext) -> S localized(context, "i18n.now", "now").to_string() }; } + if format.eq_ignore_ascii_case("countdown") { + // Minute precision for a display refreshed once a minute; whole + // minutes are truncated so a countdown never reads ahead of time. + let seconds = value.max(0.0).floor() as u64; + let (days, hours, minutes) = ( + seconds / 86_400, + seconds % 86_400 / 3_600, + seconds % 3_600 / 60, + ); + let day = localized(context, "i18n.day_suffix", "d"); + let hour = localized(context, "i18n.hour_suffix", "h"); + let minute = localized(context, "i18n.minute_suffix", "m"); + return if days > 0 { + format!("{days}{day} {hours}{hour}") + } else if hours > 0 { + format!("{hours}{hour} {minutes}{minute}") + } else if minutes > 0 { + format!("{minutes}{minute}") + } else { + format!("<1{minute}") + }; + } if format.eq_ignore_ascii_case("percent") { return format!("{value:.0}%"); } diff --git a/src/theme_engine/theme_limits.rs b/src/theme_engine/theme_limits.rs index a705ac64..e55cb1d5 100644 --- a/src/theme_engine/theme_limits.rs +++ b/src/theme_engine/theme_limits.rs @@ -1,49 +1,165 @@ use super::*; use crate::models::{limit_slug, UsageData, UsageLimit}; +/// One quota the low-usage alarm watches, named by the quota itself +/// (`owner` plus window or limit key), never by the theme that shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct AlarmReading { + pub owner: String, + pub key: String, + pub low: bool, + pub resets_at: Option, + /// The poller kept this reading from an earlier poll (a failed refresh). + pub stale: bool, +} + +/// Every quota of every enabled provider and account, independent of the +/// active theme. `owners` lists each source present in the data, including +/// ones with no reading right now, so a missing reading is not a recovery. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AlarmScan { + pub readings: Vec, + pub owners: std::collections::BTreeSet, +} + +/// Standard windows plus the known model caps. Limits of kind `session` or +/// `weekly_all` duplicate a window and unknown kinds are opaque, so neither +/// counts. A provider with accounts is read through them, once each. +pub fn alarm_scan(data: &AppUsageData, providers: ProviderSet) -> AlarmScan { + let mut scan = AlarmScan::default(); + let mut add = |owner: String, usage: Option<&UsageData>| { + scan.owners.insert(owner.clone()); + let Some(usage) = usage else { + return; + }; + let windows = [ + ("five_hour", Some(&usage.session)), + ("weekly", Some(&usage.weekly)), + ("monthly", usage.monthly.as_ref()), + ]; + let caps: Vec<_> = usage + .limits + .iter() + .filter(|limit| is_model_cap(limit)) + .collect(); + let slug_of = |limit: &UsageLimit| limit.model.as_deref().map(limit_slug); + // Name a cap by its model, as the renderer does, so the same cap + // reported under another kind or key keeps one crossing identity. Two + // caps sharing a model fall back to the raw key (a known limit). + let capped = caps.iter().map(|limit| { + let slug = slug_of(limit).filter(|slug| { + caps.iter() + .filter(|other| slug_of(other).as_ref() == Some(slug)) + .count() + == 1 + }); + let name = match slug { + Some(slug) => format!("model.{slug}"), + None => format!("limits.{}", limit.key), + }; + (name, Some(&limit.usage)) + }); + let windows = windows.into_iter().map(|(n, s)| (n.to_string(), s)); + for (name, section) in windows.chain(capped) { + if let Some(section) = section.filter(|section| section.available) { + scan.readings.push(AlarmReading { + owner: owner.clone(), + key: format!("{owner}.{name}").to_ascii_lowercase(), + low: limit_in_alarm_zone(section), + resets_at: section.resets_at, + stale: usage.stale, + }); + } + } + }; + for descriptor in PROVIDER_DESCRIPTORS { + if !providers.contains(descriptor.id) { + continue; + } + let tracked = data.accounts.iter().any(|a| a.provider == descriptor.id); + if !tracked { + if let Some(usage) = data.get(descriptor.id) { + add(descriptor.key.to_string(), Some(usage)); + } + } + } + for account in &data.accounts { + if providers.contains(account.provider) { + let owner = format!( + "accounts.{}.{}", + account.provider.descriptor().key, + account.profile.id + ); + add(owner.to_ascii_lowercase(), account.usage.as_ref()); + } + } + scan +} + +fn is_model_cap(limit: &UsageLimit) -> bool { + matches!( + limit.kind.as_str(), + "weekly_scoped" | "seven_day_opus" | "seven_day_sonnet" + ) +} + +/// The quota bindings a provider or account exposes: `limits.`, the +/// weekly model shortcut and the single active scoped cap. One rule for what +/// the theme can read and what the alarm watches. +fn limit_bases<'a>(name: &str, limits: &'a [UsageLimit]) -> Vec<(String, &'a UsageLimit)> { + let mut bases: Vec<_> = limits + .iter() + .map(|limit| (format!("{name}.limits.{}", limit.key), limit)) + .collect(); + let mut models = std::collections::BTreeMap::>::new(); + for limit in limits.iter().filter(|limit| is_model_cap(limit)) { + if let Some(model) = &limit.model { + models.entry(limit_slug(model)).or_default().push(limit); + } + } + for (model, candidates) in models { + // Never select an arbitrary quota when two distinct scopes share + // a model name. The full limit keys remain available in Studio. + if let [limit] = candidates.as_slice() { + bases.push((format!("{name}.model.{model}"), limit)); + } + } + let active: Vec<_> = limits + .iter() + .filter(|limit| limit.is_active && (limit.scope.is_some() || limit.model.is_some())) + .collect(); + // Preserve ambiguity rather than guessing which active cap binds. + if let [limit] = active.as_slice() { + bases.push((format!("{name}.scoped"), limit)); + } + bases +} + impl DataContext { - pub(super) fn insert_limits(&mut self, name: &str, usage: Option<&UsageData>, countdown: bool) { + pub(super) fn insert_limits( + &mut self, + name: &str, + usage: Option<&UsageData>, + countdown: bool, + alarm: bool, + ) { let limits = usage .map(|usage| usage.limits.as_slice()) .unwrap_or_default(); self.insert(&format!("{name}.limits.count"), limits.len() as f64); - for limit in limits { - self.insert_limit(&format!("{name}.limits.{}", limit.key), limit, countdown); - } - // The model shortcut means a weekly model cap. Other kinds remain - // individually addressable through limits., avoiding ambiguity. - let mut models = std::collections::BTreeMap::>::new(); - for limit in limits.iter().filter(|limit| { - matches!( - limit.kind.as_str(), - "weekly_scoped" | "seven_day_opus" | "seven_day_sonnet" - ) - }) { - if let Some(model) = &limit.model { - models.entry(limit_slug(model)).or_default().push(limit); - } - } - for (model, candidates) in models { - // Never select an arbitrary quota when two distinct scopes share - // a model name. The full limit keys remain available in Studio. - if let [limit] = candidates.as_slice() { - self.insert_limit(&format!("{name}.model.{model}"), limit, countdown); - } - } - let active: Vec<_> = limits - .iter() - .filter(|limit| limit.is_active && (limit.scope.is_some() || limit.model.is_some())) - .collect(); - // Preserve ambiguity rather than guessing which active cap binds. - if let [limit] = active.as_slice() { - self.insert_limit(&format!("{name}.scoped"), limit, countdown); + for (base, limit) in limit_bases(name, limits) { + self.insert_limit(&base, limit, countdown, alarm); } } - fn insert_limit(&mut self, base: &str, limit: &UsageLimit, countdown: bool) { + fn insert_limit(&mut self, base: &str, limit: &UsageLimit, countdown: bool, alarm: bool) { let percentage = limit.usage.percentage; for (metric, value) in [ ("available", limit.usage.available as u8 as f64), + ( + "alarm", + (alarm && limit_in_alarm_zone(&limit.usage)) as u8 as f64, + ), ("percentage", percentage), ("remaining", 100.0 - percentage), ( @@ -137,7 +253,7 @@ impl DataContext { pub(super) fn limit_default(&self, name: &str) -> Option { let (_, field) = Self::limit_field(name)?; match field { - "available" | "percentage" | "is_active" | "reset.unix" | "reset.seconds" + "available" | "alarm" | "percentage" | "is_active" | "reset.unix" | "reset.seconds" | "reset.minutes" | "reset.hours" | "reset.days" => Some(0.0), "remaining" => Some(100.0), "display" => Some( @@ -325,6 +441,41 @@ mod tests { ); } + #[test] + fn alarm_names_a_cap_by_its_model_unless_two_caps_share_it() { + let scan = |limits: Vec| { + let data = AppUsageData::from_iter([( + ProviderId::Claude, + UsageData { + limits, + ..Default::default() + }, + )]); + let providers = ProviderSet::from_enabled([ProviderId::Claude]); + alarm_scan(&data, providers) + .readings + .into_iter() + .map(|reading| reading.key) + .collect::>() + }; + // The same cap under another kind and key keeps one identity. + let mut renamed = limit(); + renamed.key = "seven_day_fable".into(); + renamed.kind = "seven_day_sonnet".into(); + assert_eq!(scan(vec![limit()]), scan(vec![renamed.clone()])); + assert_eq!(scan(vec![limit()]), ["claude.model.fable"]); + // An ambiguous model falls back to the raw keys. + let mut other = limit(); + other.key = "weekly_scoped_fable_other".into(); + assert_eq!( + scan(vec![limit(), other]), + [ + "claude.limits.weekly_scoped_fable", + "claude.limits.weekly_scoped_fable_other" + ] + ); + } + #[test] fn extra_limits_do_not_change_builtin_rendering_or_headlines() { let plain_usage = UsageData { @@ -348,7 +499,11 @@ mod tests { ..plain_usage }, )]); - for (_, source) in BUILTIN_THEME_SOURCES { + // Top Bar shows the Fable weekly cap by design; the others must not change. + for (_, source) in BUILTIN_THEME_SOURCES + .iter() + .filter(|(id, _)| *id != TOP_BAR_THEME_ID) + { let mut theme: ThemeDocument = serde_json::from_str(source).unwrap(); theme.prepare_runtime(); for countdown in [false, true] { diff --git a/src/theme_engine/theme_rendering.rs b/src/theme_engine/theme_rendering.rs index f5420b29..f841c839 100644 --- a/src/theme_engine/theme_rendering.rs +++ b/src/theme_engine/theme_rendering.rs @@ -122,7 +122,7 @@ pub fn render_theme_surface_with_runtime_at_scale( let alpha = ((runtime.floating_card_opacity.min(100) as u32 * 255) / 100) as u8; // Match the card to the system theme: a fixed dark card under // light-mode content destroys contrast when the widget floats. - let dark = crate::theme::is_dark_mode(); + let dark = runtime.system_dark(); let card_color = if dark { Rgba { r: 24, @@ -241,6 +241,151 @@ pub fn render_theme_surface_with_runtime_at_scale( } } +/// Logical size of the tab an auto-hiding surface leaves at its edge. +pub const SURFACE_HANDLE_WIDTH: u32 = 64; +pub const SURFACE_HANDLE_HEIGHT: u32 = 6; + +/// Expression context of a root surface at its resolved size, as its +/// placement expressions see it. +fn surface_context( + theme: &ThemeDocument, + surface_index: usize, + data: Option<&AppUsageData>, + runtime: ThemeRuntime, +) -> Option { + let surface = theme.surfaces.get(surface_index)?; + let (width, height) = resolve_surface_content_size(theme, surface_index, data, runtime); + let canvas = Canvas { + width, + width_expression: Some(surface.width.clone()), + height, + height_expression: Some(surface.height.clone()), + background: surface.background.canvas_paint(), + }; + Some(DataContext::from_usage_with_runtime(data, &canvas, runtime)) +} + +/// Whether an auto-hiding surface must stay revealed: its `force_reveal` +/// expression is non-zero. Invalid expressions never force it open. +pub fn surface_force_reveal( + theme: &ThemeDocument, + surface_index: usize, + data: Option<&AppUsageData>, + runtime: ThemeRuntime, +) -> bool { + theme + .surfaces + .get(surface_index) + .and_then(|surface| surface.placement.force_reveal.as_ref()) + .zip(surface_context(theme, surface_index, data, runtime)) + .and_then(|(expression, context)| evaluate(&expression.0, &context).ok()) + .is_some_and(|value| value.is_finite() && value != 0.0) +} + +/// Rasterize the collapsed state of an auto-hiding surface: a tab whose flat +/// side meets the screen edge, filled by the surface's `handle_background` +/// expression (dark when absent), with a short accent line coloured by its +/// `handle_color` expression. +pub fn render_surface_handle( + theme: &ThemeDocument, + surface_index: usize, + data: Option<&AppUsageData>, + runtime: ThemeRuntime, + scale: f64, + edge: SurfaceEdge, +) -> RenderedTheme { + let scale = normalized_render_scale(scale); + let width = scaled_render_dimension(SURFACE_HANDLE_WIDTH, scale); + let height = scaled_render_dimension(SURFACE_HANDLE_HEIGHT, scale); + let mut warnings = Vec::new(); + let surface = theme.surfaces.get(surface_index); + let context = surface_context(theme, surface_index, data, runtime); + let mut colour = |name: &str, expression: Option<&Expression>| { + let (surface, expression, context) = (surface?, expression?, context.as_ref()?); + evaluate_color(&expression.0, context) + .map_err(|error| warnings.push(format!("{}.{name}: {error}", surface.name))) + .ok() + }; + let placement = surface.map(|surface| &surface.placement); + let accent = colour( + "handle_color", + placement.and_then(|placement| placement.handle_color.as_ref()), + ) + .unwrap_or(Rgba { + r: 255, + g: 255, + b: 255, + a: 160, + }); + let fill = colour( + "handle_background", + placement.and_then(|placement| placement.handle_background.as_ref()), + ) + .unwrap_or(Rgba { + r: 20, + g: 21, + b: 26, + a: 232, + }); + // A hairline that reads against the fill: dark on light tabs, light on dark. + let light_fill = u32::from(fill.r) + u32::from(fill.g) + u32::from(fill.b) > 3 * 128; + let outline = if light_fill { + Rgba { + r: 0, + g: 0, + b: 0, + a: 28, + } + } else { + Rgba { + r: 255, + g: 255, + b: 255, + a: 46, + } + }; + // Draw a tab twice as tall and keep the half away from the screen edge, + // so only the outer corners are rounded. + let radius = height as f64 / 2.0; + let mut tab = vec![0u32; width as usize * height as usize * 2]; + fill_rounded(&mut tab, width, height * 2, fill, radius); + stroke_rounded_rectangle(&mut tab, width, height * 2, outline, radius, scale.max(1.0)); + let start = match edge { + SurfaceEdge::Top => height as usize * width as usize, + SurfaceEdge::Bottom => 0, + }; + let mut pixels = tab[start..start + width as usize * height as usize].to_vec(); + let line_width = scaled_render_dimension(28, scale).min(width); + let line_height = ((2.0 * scale).round() as u32).clamp(1, height); + let mut line = vec![0u32; line_width as usize * line_height as usize]; + fill_rounded( + &mut line, + line_width, + line_height, + accent, + line_height as f64 / 2.0, + ); + let inset = ((1.5 * scale).round() as u32).min(height - line_height); + let top = match edge { + SurfaceEdge::Top => inset, + SurfaceEdge::Bottom => height - line_height - inset, + }; + let left = (width - line_width) / 2; + for y in 0..line_height { + for x in 0..line_width { + let source = line[(y * line_width + x) as usize]; + let target = ((top + y) * width + left + x) as usize; + blend(&mut pixels[target], source, 1.0); + } + } + RenderedTheme { + width, + height, + pixels, + warnings, + } +} + pub(super) fn normalized_render_scale(scale: f64) -> f64 { if scale.is_finite() && scale > 0.0 { scale.clamp(0.25, 8.0) @@ -381,7 +526,19 @@ pub fn hit_test_mouse_event( height_expression: Some(surface.height.clone()), background: surface.background.canvas_paint(), }; - let (resolved, _) = resolve_objects_for(surface, &canvas, &surface.children, data, runtime); + // Only layers with handlers can be hit. Resolving every layer on each + // pointer move is costly for large surfaces, so skip it when none can be. + let interactive_children = surface.children.iter().any(|object| { + object + .mouse_events + .as_ref() + .is_some_and(|events| !events.is_empty()) + }); + let (resolved, _) = if interactive_children { + resolve_objects_for(surface, &canvas, &surface.children, data, runtime) + } else { + (Vec::new(), Vec::new()) + }; for object in resolved.into_iter().rev() { if object.opacity <= 0.0 || object.width <= 0.0 diff --git a/src/theme_engine/top_bar_tests.rs b/src/theme_engine/top_bar_tests.rs new file mode 100644 index 00000000..13a8b762 --- /dev/null +++ b/src/theme_engine/top_bar_tests.rs @@ -0,0 +1,1719 @@ +use super::*; +use crate::models::{AccountUsage, UsageData, UsageLimit, UsageSection}; +use std::time::SystemTime; + +const HOUR: u64 = 3_600; +const DAY: u64 = 86_400; +/// Bar height: 80 of content over a 4 px margin for the card's 1 px drop. +const BAR_HEIGHT: u32 = 84; +/// Margin plus card padding either side of the providers. +const EDGE: f64 = 20.0; + +fn top_bar() -> ThemeDocument { + let (_, source) = BUILTIN_THEME_SOURCES + .iter() + .find(|(id, _)| *id == TOP_BAR_THEME_ID) + .expect("Top Bar is a built-in theme"); + let mut theme: ThemeDocument = serde_json::from_str(source).unwrap(); + theme.prepare_runtime(); + theme +} + +fn section(percentage: f64, resets_in: Option) -> UsageSection { + UsageSection { + available: true, + percentage, + // A little slack keeps whole-minute countdowns stable while a test runs. + resets_at: resets_in.map(|seconds| { + SystemTime::now() + Duration::from_secs(seconds) + Duration::from_secs(30) + }), + } +} + +fn fixture(fable: bool) -> AppUsageData { + let mut claude = UsageData { + session: section(21.0, Some(3 * HOUR + 27 * 60)), + weekly: section(4.0, Some(3 * DAY + 7 * HOUR)), + ..Default::default() + }; + if fable { + claude.limits.push(UsageLimit { + key: "weekly_scoped_fable".into(), + kind: "weekly_scoped".into(), + label: "Fable".into(), + model: Some("Fable".into()), + usage: section(0.0, Some(3 * DAY + 7 * HOUR)), + ..Default::default() + }); + } + let codex = UsageData { + session: UsageSection::default(), + weekly: section(87.0, Some(4 * DAY + HOUR)), + ..Default::default() + }; + let cursor = UsageData { + session: section(41.0, Some(2 * HOUR + 9 * 60)), + weekly: section(23.0, Some(3 * DAY + 4 * HOUR)), + weekly_label: Some("API".into()), + ..Default::default() + }; + AppUsageData::from_iter([ + (ProviderId::Claude, claude), + (ProviderId::Codex, codex), + (ProviderId::Cursor, cursor), + ]) +} + +fn runtime(providers: &[ProviderId], host_width: u32) -> ThemeRuntime { + let updated = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - 20; + ThemeRuntime::from_providers(ProviderSet::from_enabled(providers.iter().copied())) + .with_nest(SurfaceNest::Floating) + .with_host_dimensions(host_width, 1080) + .with_updated_unix(Some(updated)) + .with_system_dark(false) +} + +fn alarm(enabled: bool, active: bool) -> ThemeAlarm { + ThemeAlarm { + enabled, + active, + snoozed: false, + } +} + +/// A layer's paint: its background colour, or its text colour. +fn layer_colour( + theme: &ThemeDocument, + id: &str, + data: &AppUsageData, + runtime: ThemeRuntime, +) -> Rgba { + let layer = &theme.surfaces[0].children[layer_index(theme, id)]; + let paint = match (&layer.background, &layer.content) { + (LayerBackground::Colour { colour }, _) => colour, + (_, SceneContent::Text { color, .. }) => color, + _ => panic!("{id} has no flat colour"), + }; + paint.resolve(&context(theme, Some(data), runtime)) +} + +fn rgb(hex: &str) -> Rgba { + parse_color(hex).unwrap() +} + +fn codex_weekly_at(percentage: f64) -> AppUsageData { + let mut data = fixture(true); + data.insert( + ProviderId::Codex, + UsageData { + weekly: section(percentage, Some(4 * DAY)), + ..Default::default() + }, + ); + data +} + +fn layer_index(theme: &ThemeDocument, id: &str) -> usize { + theme.surfaces[0] + .children + .iter() + .position(|layer| layer.id == id) + .unwrap_or_else(|| panic!("missing layer {id}")) +} + +fn bounds( + theme: &ThemeDocument, + id: &str, + data: &AppUsageData, + runtime: ThemeRuntime, +) -> Option<(f64, f64, f64, f64)> { + resolve_object_bounds_with_runtime(theme, 0, layer_index(theme, id), Some(data), runtime) +} + +fn context( + theme: &ThemeDocument, + data: Option<&AppUsageData>, + runtime: ThemeRuntime, +) -> DataContext { + let (width, height) = resolve_surface_content_size(theme, 0, data, runtime); + DataContext::from_usage_with_runtime( + data, + &Canvas { + width, + height, + ..Canvas::default() + }, + runtime, + ) +} + +#[test] +fn countdown_format_counts_minutes_hours_and_days() { + let context = DataContext::default(); + for (seconds, expected) in [ + (-5.0, "<1m"), + (0.0, "<1m"), + (59.9, "<1m"), + (60.0, "1m"), + (14.0 * 60.0 + 59.0, "14m"), + (3_599.0, "59m"), + (3_600.0, "1h 0m"), + (2.0 * 3_600.0 + 14.0 * 60.0 + 59.0, "2h 14m"), + (86_399.0, "23h 59m"), + (86_400.0, "1d 0h"), + (3.0 * 86_400.0 + 4.0 * 3_600.0 + 3_599.0, "3d 4h"), + ] { + let mut context = context.clone(); + context.insert("left", seconds); + assert_eq!( + format_template("{left:countdown}", &context), + expected, + "{seconds}" + ); + } +} + +#[test] +fn countdown_and_update_age_refresh_the_surface_every_minute() { + let mut theme = ThemeDocument::starter(); + assert_eq!(theme.current_time_refresh_interval(), None); + let mut layer = SceneObject::object("left", "Time left"); + layer.content = SceneContent::Text { + template: "{claude.session.reset.seconds:countdown}".into(), + font_family: "Segoe UI".into(), + font_size: 12.0.into(), + weight: FontWeight::Regular, + rendering: FontRendering::Antialiased, + contrast: 1.0.into(), + align: TextAlign::Left, + color: Paint::new("#FFFFFFFF"), + }; + theme.surfaces[0].children.push(layer); + assert_eq!( + theme.current_time_refresh_interval(), + Some(Duration::from_secs(60)) + ); + assert_eq!( + top_bar().current_time_refresh_interval(), + Some(Duration::from_secs(60)) + ); +} + +#[test] +fn update_age_is_exposed_only_when_known() { + let unknown = DataContext::from_usage(None, &Canvas::default()); + assert_eq!(unknown.get("data.updated.available"), Some(0.0)); + assert_eq!(unknown.get("data.updated.seconds"), Some(0.0)); + let known = DataContext::from_usage_with_runtime( + None, + &Canvas::default(), + runtime(&[ProviderId::Claude], 1920), + ); + assert_eq!(known.get("data.updated.available"), Some(1.0)); + let age = known.get("data.updated.seconds").unwrap(); + assert!((19.0..30.0).contains(&age), "{age}"); + assert_eq!( + format_template("{data.updated.seconds:countdown}", &known), + "<1m" + ); + assert_eq!( + ThemeRuntime::default().with_updated_unix(Some(0)), + ThemeRuntime::default() + ); +} + +#[test] +fn top_bar_is_a_top_centre_auto_hiding_floating_bar() { + let theme = top_bar(); + assert!(theme.is_builtin()); + assert_eq!(theme.name, "Top Bar"); + assert!(theme.validate().is_empty(), "{:?}", theme.validate()); + let placement = &theme.surfaces[0].placement; + assert_eq!(placement.reference.region, ReferenceRegion::Monitor); + assert_eq!(placement.nest, SurfaceNest::Floating); + assert_eq!( + (placement.horizontal, placement.surface_horizontal), + (HorizontalAnchor::Center, Some(HorizontalAnchor::Center)) + ); + assert_eq!(placement.auto_hide_edge(), Some(SurfaceEdge::Top)); + assert_eq!( + theme.edge_surface().map(|surface| surface.id.as_str()), + Some("main") + ); + // The bar paints its own card: no automatic floating card or inset. + assert_eq!( + surface_horizontal_padding(&theme, 0, runtime(&[ProviderId::Claude], 1920)), + 0 + ); +} + +#[test] +fn top_bar_lays_providers_out_left_to_right_within_the_monitor() { + let theme = top_bar(); + let data = fixture(true); + for host_width in [1920, 1280] { + let mut previous_width = 0; + // Widths of the first approved Top Bar (92 px tall); the restyle may + // not grow it. + for (providers, approved) in [ + (vec![ProviderId::Claude], 424), + (vec![ProviderId::Claude, ProviderId::Codex], 705), + ( + vec![ProviderId::Claude, ProviderId::Codex, ProviderId::Cursor], + 986, + ), + ] { + let runtime = runtime(&providers, host_width); + let (width, height) = resolve_surface_size(&theme, 0, Some(&data), runtime); + assert_eq!(height, BAR_HEIGHT); + assert!(width <= approved, "{providers:?}: {width} > {approved}"); + assert!( + width > previous_width, + "{providers:?} at {host_width}: {width}" + ); + assert!( + width <= host_width - 16, + "{providers:?}: {width} > {host_width}" + ); + previous_width = width; + let mut right = 0.0; + for provider in &providers { + let id = format!("{}-block", provider.descriptor().key); + let (x, _, w, _) = bounds(&theme, &id, &data, runtime).unwrap(); + assert!(x >= right, "{id} starts at {x}, before {right}"); + assert!(x + w <= width as f64 - EDGE + 0.5, "{id} ends at {}", x + w); + right = x + w; + } + for hidden in ProviderId::ALL.iter().filter(|p| !providers.contains(p)) { + let id = format!("{}-block", hidden.descriptor().key); + assert!(bounds(&theme, &id, &data, runtime).is_none(), "{id}"); + } + let rendered = render_theme_surface_with_runtime(&theme, 0, Some(&data), runtime); + assert!(rendered.warnings.is_empty(), "{:?}", rendered.warnings); + assert_eq!((rendered.width, rendered.height), (width, height)); + } + } +} + +#[test] +fn top_bar_shrinks_every_provider_into_a_narrow_monitor() { + let theme = top_bar(); + let mut data = fixture(true); + for provider in [ + ProviderId::Antigravity, + ProviderId::OpenCode, + ProviderId::Grok, + ProviderId::Copilot, + ] { + data.insert( + provider, + UsageData { + session: section(10.0, Some(HOUR)), + weekly: section(20.0, Some(DAY)), + ..Default::default() + }, + ); + } + let runtime = runtime(&ProviderId::ALL, 1280); + let (width, _) = resolve_surface_size(&theme, 0, Some(&data), runtime); + assert_eq!(width, 1280 - 16); + let (x, _, w, _) = bounds(&theme, "opencode-block", &data, runtime).unwrap(); + assert!( + x + w <= width as f64 - EDGE + 1.0, + "last CLI ends at {}", + x + w + ); + let (_, _, cell, _) = bounds(&theme, "claude-five_hour", &data, runtime).unwrap(); + assert!(cell < 112.0 && cell > 40.0, "{cell}"); +} + +#[test] +fn claude_shows_the_fable_weekly_cap_only_when_reported() { + let theme = top_bar(); + let runtime = runtime(&[ProviderId::Claude, ProviderId::Codex], 1920); + let with = fixture(true); + let without = fixture(false); + assert!(bounds(&theme, "claude-fable", &with, runtime).is_some()); + assert!(bounds(&theme, "claude-fable", &without, runtime).is_none()); + let wide = bounds(&theme, "claude-block", &with, runtime).unwrap().2; + let narrow = bounds(&theme, "claude-block", &without, runtime).unwrap().2; + assert_eq!(wide - narrow, 128.0); + // Codex has no five-hour window here: its cell stays, marked n/a. + assert!(bounds(&theme, "codex-five_hour-na", &with, runtime).is_some()); + assert!(bounds(&theme, "codex-five_hour-value", &with, runtime).is_none()); + assert!(bounds(&theme, "codex-weekly-value", &with, runtime).is_some()); + assert!(bounds(&theme, "codex-weekly-pill", &with, runtime).is_some()); + assert!(bounds(&theme, "claude-weekly-pill", &with, runtime).is_none()); +} + +#[test] +fn top_bar_status_and_handle_follow_freshness_and_usage() { + let theme = top_bar(); + let providers = [ProviderId::Claude, ProviderId::Codex]; + let mut data = fixture(true); + let live = runtime(&providers, 1920); + let handle = |data: &AppUsageData, runtime| { + let expression = theme.surfaces[0].placement.handle_color.as_ref().unwrap(); + evaluate_color(&expression.0, &context(&theme, Some(data), runtime)).unwrap() + }; + let status = + |id: &str, data: &AppUsageData, runtime| bounds(&theme, id, data, runtime).is_some(); + assert!(status("status-live", &data, live)); + assert!(status("status-live-pill", &data, live)); + assert!(!status("status-stale", &data, live)); + let SceneContent::Text { template, .. } = + &theme.surfaces[0].children[layer_index(&theme, "status-live")].content + else { + panic!("status-live is a text layer"); + }; + assert_eq!( + format_template(template, &context(&theme, Some(&data), live)), + "Live \u{b7} updated <1m ago" + ); + // Live is a green "working" pill: tinted fill, dark green text, green dot. + for (dark, tint, ink, dot) in [ + (false, "#E4FCEF", "#0F7A4A", "#18C669"), + (true, "#2FD27F1F", "#4ADE94", "#2FD27F"), + ] { + let mode = live.with_system_dark(dark); + assert_eq!( + layer_colour(&theme, "status-live-pill", &data, mode), + rgb(tint) + ); + assert_eq!(layer_colour(&theme, "status-live", &data, mode), rgb(ink)); + assert_eq!( + layer_colour(&theme, "status-live-dot", &data, mode), + rgb(dot) + ); + } + // Codex weekly is at 87%: the collapsed handle warns in amber. + assert_eq!(handle(&data, live), rgb("#FFA100")); + assert_eq!(handle(&data, live.with_system_dark(true)), rgb("#FFB020")); + data.insert( + ProviderId::Claude, + UsageData { + session: section(93.0, Some(HOUR)), + stale: true, + ..Default::default() + }, + ); + assert!(status("status-stale", &data, live)); + assert!(!status("status-live", &data, live)); + let SceneContent::Text { template, .. } = + &theme.surfaces[0].children[layer_index(&theme, "status-stale")].content + else { + panic!("status-stale is a text layer"); + }; + assert_eq!( + format_template(template, &context(&theme, Some(&data), live)), + "Stale \u{b7} updated <1m ago" + ); + // Stale is an amber "waiting" pill, and the handle goes quiet. + assert_eq!( + layer_colour(&theme, "status-stale-pill", &data, live), + rgb("#FFF4E0") + ); + assert_eq!( + layer_colour(&theme, "status-stale", &data, live), + rgb("#8A5600") + ); + assert_eq!(handle(&data, live), rgb("#C7C7C7")); + let mut fresh = data.clone(); + fresh.insert( + ProviderId::Claude, + UsageData { + session: section(93.0, Some(HOUR)), + ..Default::default() + }, + ); + assert_eq!(handle(&fresh, live), rgb("#EF4444")); + let calm = AppUsageData::from_iter([( + ProviderId::Claude, + UsageData { + session: section(10.0, Some(HOUR)), + ..Default::default() + }, + )]); + assert_eq!(handle(&calm, live), rgb("#6B6B6B")); + assert_eq!(handle(&calm, live.with_system_dark(true)), rgb("#9B9BA2")); + let loading = live.with_poll_state(false, false); + assert!(status("status-waiting", &calm, loading)); + assert!(status("status-waiting-pill", &calm, loading)); +} + +#[test] +fn the_longest_status_pill_clears_the_last_providers_name() { + let theme = top_bar(); + let mut data = fixture(true); + data.insert( + ProviderId::Claude, + UsageData { + stale: true, + ..data.get(ProviderId::Claude).unwrap().clone() + }, + ); + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + // GDI widths of the last provider's 12 px semibold name. + for (providers, name_width) in [ + (vec![ProviderId::Claude], 42.0), + (vec![ProviderId::Claude, ProviderId::Codex], 38.0), + ( + vec![ProviderId::Claude, ProviderId::Codex, ProviderId::Cursor], + 40.0, + ), + ] { + let runtime = + runtime(&providers, 1920).with_updated_unix(Some(now - (23 * HOUR + 59 * 60 + 30))); + let SceneContent::Text { template, .. } = + &theme.surfaces[0].children[layer_index(&theme, "status-stale")].content + else { + panic!("status-stale is a text layer"); + }; + assert_eq!( + format_template(template, &context(&theme, Some(&data), runtime)), + "Stale \u{b7} updated 23h 59m ago" + ); + let (width, _) = resolve_surface_size(&theme, 0, Some(&data), runtime); + let last = providers.last().unwrap().descriptor().key; + let (block, ..) = bounds(&theme, &format!("{last}-block"), &data, runtime).unwrap(); + let (pill, _, pill_width, _) = bounds(&theme, "status-stale-pill", &data, runtime).unwrap(); + assert!( + pill >= block + 17.0 + name_width + 16.0 - 0.5, + "{last}: pill at {pill}" + ); + assert!(pill + pill_width <= width as f64 - EDGE + 0.5); + } +} + +#[test] +fn handle_is_a_flat_edged_tab_matching_the_card_and_tinted_by_its_expression() { + let theme = top_bar(); + let data = fixture(true); + for (dark, card) in [(false, 0xFF), (true, 0x19)] { + let runtime = + runtime(&[ProviderId::Claude, ProviderId::Codex], 1920).with_system_dark(dark); + for (scale, size) in [(1.0, (64, 6)), (1.5, (96, 9))] { + for edge in [SurfaceEdge::Top, SurfaceEdge::Bottom] { + let handle = render_surface_handle(&theme, 0, Some(&data), runtime, scale, edge); + assert!(handle.warnings.is_empty(), "{:?}", handle.warnings); + assert_eq!((handle.width, handle.height), size); + let pixel = |x: u32, y: u32| handle.pixels[(y * handle.width + x) as usize]; + let (flat, round) = match edge { + SurfaceEdge::Top => (0, handle.height - 1), + SurfaceEdge::Bottom => (handle.height - 1, 0), + }; + // Corners touching the screen edge are square; the far ones rounded. + assert!(pixel(0, flat) >> 24 > 200, "{edge:?} {scale}"); + assert!(pixel(0, round) >> 24 < 200, "{edge:?} {scale}"); + // The tab is the card's own surface colour in either mode. + let fill = pixel(handle.width / 4, handle.height / 2); + assert!( + (i32::from((fill >> 8 & 0xff) as u8) - card).abs() <= 6, + "dark={dark} {fill:08x}" + ); + // The amber accent line sits in the middle of the tab. + let middle = pixel(handle.width / 2, handle.height / 2); + assert!((middle >> 16 & 0xff) > (middle & 0xff) + 40, "{middle:08x}"); + } + } + } +} + +#[test] +fn edge_preferences_override_hiding_and_display_only_for_edge_surfaces() { + let mut theme = top_bar(); + theme.apply_edge_preferences(Some(false), Some(1)); + assert!(!theme.surfaces[0].placement.auto_hide); + assert_eq!(theme.surfaces[0].placement.reference.display, 1); + assert_eq!(theme.placement, theme.surfaces[0].placement); + assert_eq!(theme.surfaces[0].placement.edge(), Some(SurfaceEdge::Top)); + assert_eq!(theme.surfaces[0].placement.auto_hide_edge(), None); + + let mut classic = ThemeDocument::starter(); + let before = serde_json::to_string(&classic).unwrap(); + classic.apply_edge_preferences(Some(true), Some(1)); + assert_eq!(serde_json::to_string(&classic).unwrap(), before); + assert!(classic.edge_surface().is_none()); +} + +#[test] +fn auto_hide_needs_a_floating_root_docked_inside_a_top_or_bottom_edge() { + let mut placement = top_bar().surfaces[0].placement.clone(); + assert_eq!(placement.auto_hide_edge(), Some(SurfaceEdge::Top)); + placement.vertical = VerticalAnchor::Bottom; + placement.surface_vertical = Some(VerticalAnchor::Bottom); + assert_eq!(placement.auto_hide_edge(), Some(SurfaceEdge::Bottom)); + placement.surface_vertical = Some(VerticalAnchor::Top); + assert_eq!(placement.edge(), None); + placement.vertical = VerticalAnchor::Center; + placement.surface_vertical = Some(VerticalAnchor::Center); + assert_eq!(placement.edge(), None); + placement.vertical = VerticalAnchor::Top; + placement.surface_vertical = None; + placement.nest = SurfaceNest::Taskbar; + assert_eq!(placement.edge(), None); + placement.nest = SurfaceNest::Floating; + placement.auto_hide = false; + assert_eq!( + (placement.edge(), placement.auto_hide_edge()), + (Some(SurfaceEdge::Top), None) + ); + // Themes that never set it keep their saved form byte for byte. + let json = serde_json::to_value(ThemeDocument::starter()).unwrap(); + for field in [ + "auto_hide", + "handle_color", + "handle_background", + "force_reveal", + ] { + assert!(!json.to_string().contains(field), "{field}"); + } +} + +#[test] +fn handle_colour_must_be_colour_text() { + let mut theme = top_bar(); + theme.surfaces[0].placement.handle_color = Some(Expression("42".into())); + assert!(theme + .validate() + .iter() + .any(|error| error.contains("handle_color"))); + theme.surfaces[0].placement.handle_color = Some(Expression("\"#12345\"".into())); + assert!(theme + .validate() + .iter() + .any(|error| error.contains("handle_color"))); + theme.surfaces[0].placement.handle_color = Some(Expression( + "if(claude.session.percentage > 50, \"#FF0000\", \"#00FF00AA\")".into(), + )); + assert!(theme.validate().is_empty(), "{:?}", theme.validate()); + theme.surfaces[0].placement.handle_background = Some(Expression("1".into())); + assert!(theme + .validate() + .iter() + .any(|error| error.contains("handle_background"))); + theme.surfaces[0].placement.handle_background = None; + theme.surfaces[0].placement.force_reveal = Some(Expression("alarm.actve".into())); + assert!(theme + .validate() + .iter() + .any(|error| error.contains("force_reveal"))); + // Layer colours accept colour expressions too, and reject bad ones. + let card = layer_index(&theme, "card"); + theme.surfaces[0].placement.force_reveal = None; + theme.surfaces[0].children[card].background = LayerBackground::Colour { + colour: Paint::new("if(system.dark, 7, 8)"), + }; + assert!(theme.validate().iter().any(|error| error.contains("Card"))); +} + +/// Every colour in the theme, evaluated in one mode: background, border and +/// text paints of every layer plus the handle's two placement colours. +fn every_colour( + theme: &ThemeDocument, + context: &DataContext, +) -> Vec<(String, Result)> { + let surface = &theme.surfaces[0]; + let mut colours = Vec::new(); + let mut add = |label: String, colour: &str| { + let resolved = parse_color(colour) + .ok_or(String::new()) + .or_else(|_| evaluate_color(colour, context)); + colours.push((label, resolved)); + }; + for layer in std::iter::once(surface).chain(&surface.children) { + if let LayerBackground::Colour { colour } = &layer.background { + add(format!("{}.background", layer.id), &colour.color); + } + if let Some(border) = &layer.border { + add(format!("{}.border", layer.id), &border.color.color); + } + if let SceneContent::Text { color, .. } = &layer.content { + add(format!("{}.color", layer.id), &color.color); + } + } + for (name, expression) in [ + ("handle_color", &surface.placement.handle_color), + ("handle_background", &surface.placement.handle_background), + ] { + add(name.into(), &expression.as_ref().unwrap().0); + } + colours +} + +#[test] +fn light_and_dark_variants_both_validate_and_differ() { + let theme = top_bar(); + assert!(theme.validate().is_empty(), "{:?}", theme.validate()); + let data = codex_weekly_at(93.0); + let base = runtime(&[ProviderId::Claude, ProviderId::Codex], 1920); + let [light, dark] = [false, true].map(|dark| { + let colours = every_colour( + &theme, + &context(&theme, Some(&data), base.with_system_dark(dark)), + ); + assert!(colours.len() > 150, "{}", colours.len()); + let failures: Vec<_> = colours + .iter() + .filter(|(_, colour)| colour.is_err()) + .collect(); + assert!(failures.is_empty(), "dark={dark}: {failures:?}"); + colours + }); + // The card and its text really switch, not just one layer. + let pick = |colours: &[(String, Result)], id: &str| { + colours + .iter() + .find(|(label, _)| label == id) + .unwrap_or_else(|| panic!("no {id}")) + .1 + .clone() + .unwrap() + }; + for (id, light_hex, dark_hex) in [ + ("card.background", "#FFFFFF", "#19191C"), + ("card.border", "#ECECEC", "#FFFFFF14"), + ("claude-name.color", "#171717", "#EDEDEF"), + ("claude-weekly-label.color", "#6B6B6B", "#9B9BA2"), + ("claude-weekly-track.background", "#EBEBEB", "#2A2A2F"), + ("codex-divider.background", "#EBEBEB", "#2A2A2F"), + ("handle_background", "#FFFFFF", "#19191C"), + ] { + assert_eq!(pick(&light, id), rgb(light_hex), "{id}"); + assert_eq!(pick(&dark, id), rgb(dark_hex), "{id}"); + } + let changed = light + .iter() + .zip(&dark) + .filter(|((_, light), (_, dark))| light != dark) + .count(); + assert!(changed > 100, "only {changed} colours follow the mode"); +} + +#[test] +fn level_colours_switch_at_70_and_90_in_both_modes() { + let theme = top_bar(); + let providers = [ProviderId::Claude, ProviderId::Codex]; + // (used %, pill shown, value text, bar fill) per mode. + let light = [ + (69.0, false, "#171717", "#171717"), + (70.0, true, "#8A5600", "#FFA100"), + (89.0, true, "#8A5600", "#FFA100"), + (90.0, true, "#B42318", "#EF4444"), + (93.0, true, "#B42318", "#EF4444"), + ]; + let dark = [ + (69.0, false, "#EDEDEF", "#EDEDEF"), + (70.0, true, "#FFC35C", "#FFB020"), + (89.0, true, "#FFC35C", "#FFB020"), + (90.0, true, "#FF8F8F", "#F26B6B"), + (93.0, true, "#FF8F8F", "#F26B6B"), + ]; + for (is_dark, levels) in [(false, light), (true, dark)] { + let runtime = runtime(&providers, 1920).with_system_dark(is_dark); + for (percentage, pill, value, fill) in levels { + let data = codex_weekly_at(percentage); + let label = format!("dark={is_dark} {percentage}%"); + assert_eq!( + bounds(&theme, "codex-weekly-pill", &data, runtime).is_some(), + pill, + "{label}" + ); + assert_eq!( + layer_colour(&theme, "codex-weekly-value", &data, runtime), + rgb(value), + "{label}" + ); + assert_eq!( + layer_colour(&theme, "codex-weekly-fill", &data, runtime), + rgb(fill), + "{label}" + ); + } + // The pill behind the percentage is the matching tint. + for (percentage, light_tint, dark_tint) in [ + (75.0, "#FFF4E0", "#FFB0201F"), + (93.0, "#FEF0EF", "#F26B6B1F"), + ] { + assert_eq!( + layer_colour( + &theme, + "codex-weekly-pill", + &codex_weekly_at(percentage), + runtime + ), + rgb(if is_dark { dark_tint } else { light_tint }) + ); + } + } +} + +/// Keys of the quotas the alarm watches that are at or below 5% left. +fn low_keys(data: &AppUsageData, enabled: ProviderSet) -> Vec { + alarm_scan(data, enabled) + .readings + .into_iter() + .filter(|reading| reading.low) + .map(|reading| reading.key) + .collect() +} + +fn cap(key: &str, kind: &str, model: &str, percentage: f64) -> UsageLimit { + UsageLimit { + key: key.into(), + kind: kind.into(), + label: model.into(), + model: Some(model.into()), + usage: section(percentage, Some(DAY)), + ..Default::default() + } +} + +#[test] +fn the_alarm_starts_at_five_percent_left_for_enabled_providers_whatever_the_theme_shows() { + let enabled = ProviderSet::from_enabled([ProviderId::Claude, ProviderId::Codex]); + // Exactly 5% left alarms; 6% left does not. + assert_eq!(low_keys(&codex_weekly_at(95.0), enabled), ["codex.weekly"]); + assert!(low_keys(&codex_weekly_at(94.0), enabled).is_empty()); + // What reads as 5% once rounded counts; what reads as 6% does not. + assert_eq!(low_keys(&codex_weekly_at(94.6), enabled), ["codex.weekly"]); + assert!(low_keys(&codex_weekly_at(94.5), enabled).is_empty()); + // A disabled provider never alarms. + let mut data = codex_weekly_at(10.0); + data.insert( + ProviderId::Cursor, + UsageData { + session: section(99.0, Some(HOUR)), + ..Default::default() + }, + ); + assert!(low_keys(&data, enabled).is_empty()); + // Model caps count (Fable and Sonnet alike), whatever the active theme binds. + let mut claude = data.get(ProviderId::Claude).unwrap().clone(); + for (key, kind, model) in [ + ("weekly_scoped_fable", "weekly_scoped", "Fable"), + ("seven_day_sonnet", "seven_day_sonnet", "Sonnet"), + ("seven_day_opus", "seven_day_opus", "Opus"), + ] { + claude.limits.retain(|limit| limit.key != key); + claude.limits.push(cap(key, kind, model, 97.0)); + } + data.insert(ProviderId::Claude, claude); + assert_eq!( + low_keys(&data, enabled), + [ + "claude.model.fable", + "claude.model.sonnet", + "claude.model.opus" + ] + ); +} + +#[test] +fn limits_that_duplicate_a_window_or_have_an_unknown_kind_never_count() { + let enabled = ProviderSet::from_enabled([ProviderId::Claude]); + let mut usage = UsageData { + session: section(29.0, Some(HOUR)), + weekly: section(26.0, Some(DAY)), + ..Default::default() + }; + // The shape of a real usage cache: session and weekly_all repeat the + // windows, iguana_necktie is opaque. + for (key, kind, percentage) in [ + ("session", "session", 99.0), + ("weekly_all", "weekly_all", 99.0), + ("iguana_necktie", "iguana_necktie", 99.0), + ] { + usage.limits.push(cap(key, kind, "", percentage)); + } + let data = AppUsageData::from_iter([(ProviderId::Claude, usage.clone())]); + assert!(low_keys(&data, enabled).is_empty()); + // A promoted session limit and the window it duplicates are one quota. + usage.session = section(97.0, Some(HOUR)); + usage.limits[0].usage = section(97.0, Some(HOUR)); + let data = AppUsageData::from_iter([(ProviderId::Claude, usage)]); + assert_eq!(low_keys(&data, enabled), ["claude.five_hour"]); +} + +fn account(provider: ProviderId, id: &str, selected: bool, usage: UsageData) -> AccountUsage { + AccountUsage { + provider, + profile: crate::accounts::AccountProfile { + id: id.into(), + name: id.into(), + enabled: true, + ..Default::default() + }, + source_signature: String::new(), + source_path: None, + usage: Some(usage), + error: None, + selected, + } +} + +#[test] +fn every_account_counts_not_only_the_selected_one() { + let enabled = ProviderSet::from_enabled([ProviderId::Claude, ProviderId::Codex]); + let healthy = UsageData { + session: section(10.0, Some(HOUR)), + weekly: section(10.0, Some(DAY)), + ..Default::default() + }; + let mut low_weekly = healthy.clone(); + low_weekly.weekly = section(98.0, Some(DAY)); + let mut data = AppUsageData::from_iter([(ProviderId::Claude, healthy.clone())]); + data.accounts = vec![ + account(ProviderId::Claude, "personal", true, healthy.clone()), + account(ProviderId::Claude, "work", false, low_weekly), + ]; + // The selected provider-level reading is not counted twice. + assert_eq!(low_keys(&data, enabled), ["accounts.claude.work.weekly"]); + // A Codex weekly-only response is keyed as the weekly window. + data.accounts = vec![account( + ProviderId::Codex, + "work", + true, + UsageData { + weekly: section(97.0, Some(DAY)), + ..Default::default() + }, + )]; + assert_eq!(low_keys(&data, enabled), ["accounts.codex.work.weekly"]); +} + +#[test] +fn an_almost_spent_limit_is_flagged_and_holds_the_bar_open() { + let theme = top_bar(); + let providers = [ProviderId::Claude, ProviderId::Codex]; + let data = codex_weekly_at(96.0); + for dark in [false, true] { + let base = runtime(&providers, 1920).with_system_dark(dark); + let sounding = base.with_alarm(alarm(true, true)); + let ctx = context(&theme, Some(&data), sounding); + assert_eq!(ctx.get("codex.weekly.alarm"), Some(1.0)); + assert_eq!(ctx.get("claude.weekly.alarm"), Some(0.0)); + assert_eq!(ctx.get("claude.model.fable.alarm"), Some(0.0)); + // A solid red pill with white text, a red bar, a red label. + assert!(bounds(&theme, "codex-weekly-pill", &data, sounding).is_some()); + let red = rgb(if dark { "#F26B6B" } else { "#EF4444" }); + assert_eq!( + layer_colour(&theme, "codex-weekly-pill", &data, sounding), + red + ); + assert_eq!( + layer_colour(&theme, "codex-weekly-fill", &data, sounding), + red + ); + assert_eq!( + layer_colour(&theme, "codex-weekly-value", &data, sounding), + rgb("#FFFFFF") + ); + assert_eq!( + layer_colour(&theme, "codex-weekly-label", &data, sounding), + rgb(if dark { "#FF8F8F" } else { "#B42318" }) + ); + assert!(surface_force_reveal(&theme, 0, Some(&data), sounding)); + + // Snoozed: still flagged, no longer forced open. + let snoozed = base.with_alarm(ThemeAlarm { + enabled: true, + active: false, + snoozed: true, + }); + assert_eq!( + layer_colour(&theme, "codex-weekly-pill", &data, snoozed), + red + ); + assert!(!surface_force_reveal(&theme, 0, Some(&data), snoozed)); + + // Turned off: an ordinary 90%+ limit, never forced open. + let off = base.with_alarm(alarm(false, false)); + assert_eq!( + context(&theme, Some(&data), off).get("codex.weekly.alarm"), + Some(0.0) + ); + assert_eq!( + layer_colour(&theme, "codex-weekly-pill", &data, off), + rgb(if dark { "#F26B6B1F" } else { "#FEF0EF" }) + ); + assert!(!surface_force_reveal(&theme, 0, Some(&data), off)); + } + // Six percent left is only the 90%+ warning. + let six = codex_weekly_at(94.0); + let ctx = context( + &theme, + Some(&six), + runtime(&providers, 1920).with_alarm(alarm(true, false)), + ); + assert_eq!(ctx.get("codex.weekly.alarm"), Some(0.0)); +} + +#[test] +fn the_snooze_button_shows_and_takes_clicks_only_while_the_alarm_holds_the_bar() { + let theme = top_bar(); + let providers = [ProviderId::Claude, ProviderId::Codex]; + let data = codex_weekly_at(96.0); + let base = runtime(&providers, 1920); + let sounding = base.with_alarm(alarm(true, true)); + let (x, y, w, h) = bounds(&theme, "snooze-button", &data, sounding).unwrap(); + let centre = (x + w / 2.0, y + h / 2.0); + let hit = |runtime| hit_test_mouse_event(&theme, 0, centre.0, centre.1, Some(&data), runtime); + assert_eq!(hit(sounding).as_deref(), Some("snooze-button")); + assert_eq!( + mouse_event_script(&theme, 0, "snooze-button", MouseEventKind::Click), + Some("snooze_alarm()") + ); + assert_eq!( + execute_mouse_actions( + &theme, + 0, + "snooze-button", + "snooze_alarm()", + Some(&data), + sounding, + &mut HashMap::new(), + ) + .unwrap(), + [MouseActionEffect::SnoozeAlarm] + ); + // Its icon and text sit on it. + for part in ["snooze-button-icon", "snooze-button-text"] { + let (px, py, pw, ph) = bounds(&theme, part, &data, sounding).unwrap(); + assert!( + px >= x && px + pw <= x + w && py >= y && py + ph <= y + h, + "{part}" + ); + } + // No alarm, a snoozed one, or the alarm turned off: no button to hit. + let snoozed = ThemeAlarm { + enabled: true, + active: false, + snoozed: true, + }; + for quiet in [alarm(true, false), snoozed, alarm(false, false)] { + let runtime = base.with_alarm(quiet); + for id in ["snooze-button", "snooze-button-icon", "snooze-button-text"] { + assert!( + bounds(&theme, id, &data, runtime).is_none(), + "{quiet:?} {id}" + ); + } + assert_ne!(hit(runtime).as_deref(), Some("snooze-button"), "{quiet:?}"); + } +} + +/// Something the bar paints, in physical pixels: a text's ink or a filled +/// shape. Edges are half-open, so two marks that touch are 0 px apart. +#[derive(Clone, Debug)] +struct Mark { + id: String, + text: Option, + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +impl Mark { + /// Clear pixels between two marks; negative when they overlap. + fn gap(&self, other: &Mark) -> i32 { + let across = (other.left - self.right).max(self.left - other.right); + let down = (other.top - self.bottom).max(self.top - other.bottom); + across.max(down) + } + + fn within(&self, other: &Mark) -> bool { + self.left >= other.left + && self.top >= other.top + && self.right <= other.right + && self.bottom <= other.bottom + } +} + +impl std::fmt::Display for Mark { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id)?; + if let Some(text) = &self.text { + write!(f, " \"{text}\"")?; + } + write!( + f, + " [{},{}..{},{}]", + self.left, self.top, self.right, self.bottom + ) + } +} + +/// The shape a mark is designed to sit on: a percentage on its level pill, +/// a status text or dot on its status pill, a bar fill on its track, a +/// provider dot in its halo, the Snooze button's icon and text on the button. +fn backdrop(id: &str) -> Option { + if id.starts_with("status-") && !id.ends_with("-pill") { + return Some(format!("{}-pill", id.trim_end_matches("-dot"))); + } + if id.starts_with("snooze-button-") { + return Some("snooze-button".into()); + } + [("-value", "-pill"), ("-fill", "-track"), ("-dot", "-halo")] + .into_iter() + .find_map(|(mark, under)| id.strip_suffix(mark).map(|owner| format!("{owner}{under}"))) +} + +/// Everything the Top Bar paints at a scale, as the renderer composites it: +/// each text's ink inside its layer and every parent clip, and each filled +/// shape except the card and its drop. Also returns the texts drawn narrower +/// than with unlimited room, which were cut off or ellipsized. +fn painted_marks( + theme: &ThemeDocument, + data: &AppUsageData, + runtime: ThemeRuntime, + scale: f64, +) -> (Vec, Vec) { + let surface = &theme.surfaces[0]; + let (width, height) = resolve_surface_content_size(theme, 0, Some(data), runtime); + let canvas = Canvas { + width, + width_expression: Some(surface.width.clone()), + height, + height_expression: Some(surface.height.clone()), + background: surface.background.canvas_paint(), + }; + let context = DataContext::from_usage_with_runtime(Some(data), &canvas, runtime); + let (objects, warnings) = + resolve_objects_for(surface, &canvas, &surface.children, Some(data), runtime); + assert!(warnings.is_empty(), "{warnings:?}"); + let physical = |logical: f64| (logical * scale).round() as i32; + let (mut marks, mut cut) = (Vec::new(), Vec::new()); + for object in objects.iter().filter(|object| object.opacity > 0.0) { + let id = object.source.id.clone(); + let (left, top) = (physical(object.x), physical(object.y)); + let width = (object.width * scale).round().max(1.0) as u32; + let height = (object.height * scale).round().max(1.0) as u32; + let local = context.clone().with_object(object); + match (&object.source.content, &object.source.background) { + ( + SceneContent::Text { + template, + font_family, + font_size, + weight, + rendering, + align, + .. + }, + _, + ) => { + let text = format_template(template, &local); + if text.is_empty() { + continue; + } + let size = (evaluate(&font_size.0, &local).unwrap() * scale).max(1.0); + let draw = |width: u32, align: TextAlign| { + let mut pixels = vec![0u32; width as usize * height as usize]; + render_text_mask( + &mut pixels, + width, + height, + &text, + font_family, + size, + weight.gdi_weight(), + *rendering, + 1.0, + align, + Rgba { + r: 255, + g: 255, + b: 255, + a: 255, + }, + ); + pixels + }; + let pixels = draw(width, *align); + let mut ink: Option = None; + for y in 0..height { + for x in 0..width { + if pixels[(y * width + x) as usize] >> 24 == 0 { + continue; + } + let (px, py) = (left + x as i32, top + y as i32); + let centre = ((px as f64 + 0.5) / scale, (py as f64 + 0.5) / scale); + if object + .clip + .iter() + .any(|region| !point_in_clip(centre, *region)) + { + continue; + } + let mark = ink.get_or_insert_with(|| Mark { + id: id.clone(), + text: Some(text.clone()), + left: px, + top: py, + right: px, + bottom: py, + }); + mark.left = mark.left.min(px); + mark.top = mark.top.min(py); + mark.right = mark.right.max(px + 1); + mark.bottom = mark.bottom.max(py + 1); + } + } + let room = (2 * width).max(512); + let unlimited = draw(room, TextAlign::Left); + let columns: Vec = (0..room) + .filter(|x| (0..height).any(|y| unlimited[(y * room + x) as usize] >> 24 > 0)) + .collect(); + let full = match (columns.first(), columns.last()) { + (Some(first), Some(last)) => (last - first + 1) as i32, + _ => 0, + }; + match ink { + Some(mark) => { + let drawn = mark.right - mark.left; + if drawn < full - 1 { + cut.push(format!("{mark} is cut off: {drawn} of {full} px drawn")); + } + marks.push(mark); + } + None => cut.push(format!("{id} \"{text}\" draws nothing")), + } + } + (_, LayerBackground::Colour { colour }) + if id != "card" && !id.starts_with("drop-") && colour.resolve(&local).a > 0 => + { + marks.push(Mark { + id, + text: None, + left, + top, + right: left + width as i32, + bottom: top + height as i32, + }); + } + _ => {} + } + } + (marks, cut) +} + +/// Every pair of marks keeps `clearance` px apart unless one is designed to +/// sit on the other, and then it stays inside it; every mark stays inside +/// `inner`. Returns the problems and the tightest independent pair. +fn crowding(marks: &[Mark], inner: &Mark, clearance: i32) -> (Vec, (i32, String)) { + let mut problems = Vec::new(); + let mut tightest = (i32::MAX, String::new()); + for (index, mark) in marks.iter().enumerate() { + if !mark.within(inner) { + problems.push(format!("{mark} leaves the card's inner padding {inner}")); + } + for other in &marks[index + 1..] { + let (on, under) = if backdrop(&mark.id).as_deref() == Some(other.id.as_str()) { + (mark, other) + } else if backdrop(&other.id).as_deref() == Some(mark.id.as_str()) { + (other, mark) + } else { + let gap = mark.gap(other); + if gap < clearance { + problems.push(format!( + "{mark} and {other} are {gap} px apart, need {clearance}" + )); + } + if gap < tightest.0 { + tightest = (gap, format!("{} ~ {}", mark.id, other.id)); + } + continue; + }; + if !on.within(under) { + problems.push(format!("{on} spills out of {under}")); + } + } + } + (problems, tightest) +} + +/// Usage at one level on every limit of every provider, each resetting in +/// 23h 59m, the widest countdown. Codex reports a 30-day window, the widest +/// label. +fn everything_at(percentage: f64) -> AppUsageData { + let window = || section(percentage, Some(23 * HOUR + 59 * 60)); + let usage = |weekly_label: Option<&str>| UsageData { + session: window(), + weekly: window(), + weekly_label: weekly_label.map(Into::into), + ..Default::default() + }; + let mut claude = usage(None); + claude.limits.push(UsageLimit { + key: "weekly_scoped_fable".into(), + kind: "weekly_scoped".into(), + label: "Fable".into(), + model: Some("Fable".into()), + usage: window(), + ..Default::default() + }); + let mut opencode = usage(None); + opencode.monthly = Some(window()); + AppUsageData::from_iter([ + (ProviderId::Claude, claude), + (ProviderId::Codex, usage(Some("30d"))), + (ProviderId::Cursor, usage(Some("API"))), + (ProviderId::Antigravity, usage(None)), + (ProviderId::Copilot, usage(None)), + (ProviderId::Grok, usage(None)), + (ProviderId::OpenCode, opencode), + ]) +} + +/// Lay the bar out in each language, state, provider set and scale and +/// collect everything that touches, leaves the card or is cut short. `quick` +/// trims the matrix for the all-languages run. +fn top_bar_problems(languages: &[LanguageId], quick: bool) -> (Vec, u32) { + let theme = top_bar(); + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let longest = 23 * HOUR + 59 * 60 + 30; + let mut calm = fixture(true); + for provider in [ + ProviderId::Antigravity, + ProviderId::Copilot, + ProviderId::Grok, + ProviderId::OpenCode, + ] { + calm.insert( + provider, + UsageData { + session: section(12.0, Some(2 * HOUR)), + weekly: section(34.0, Some(5 * DAY)), + ..Default::default() + }, + ); + } + let stale_at = |percentage| { + let mut stale = everything_at(percentage); + for provider in ProviderId::ALL { + let usage = stale.get(provider).unwrap().clone(); + stale.insert( + provider, + UsageData { + stale: true, + ..usage + }, + ); + } + stale + }; + // (state, data, seconds since the last update, alarm sounding, loading, countdown) + let states = [ + ("normal", calm.clone(), 20, false, false, false), + ("amber", everything_at(75.0), longest, false, false, false), + ("red", everything_at(93.0), longest, false, false, false), + ("full", everything_at(100.0), longest, false, false, false), + ("alarm", everything_at(96.0), 20, true, false, false), + // The Snooze button beside the longest status pill. + ("alarm-stale", stale_at(96.0), longest, true, false, false), + ("stale", stale_at(93.0), longest, false, false, false), + ("waiting", calm.clone(), 20, false, true, false), + ("countdown", everything_at(0.0), longest, false, false, true), + ("missing", calm.clone(), longest, false, false, false), + ]; + // Four or more providers can squeeze the bar to the monitor's width, + // shrinking every cell, so a label may be ellipsized there; nothing may + // overlap anywhere. Geometry ignores the mode, so those bigger sets run + // in light mode only. + let sets: [&[ProviderId]; 6] = [ + &[ProviderId::Claude], + &[ProviderId::Claude, ProviderId::Codex], + &[ProviderId::Claude, ProviderId::Codex, ProviderId::Cursor], + &[ + ProviderId::Antigravity, + ProviderId::Grok, + ProviderId::OpenCode, + ], + &[ + ProviderId::Claude, + ProviderId::Codex, + ProviderId::Cursor, + ProviderId::OpenCode, + ], + &ProviderId::ALL, + ]; + let (mut problems, mut squeezed_bars) = (Vec::new(), 0); + for providers in sets + .into_iter() + .filter(|providers| !quick || providers.len() == 2 || providers.len() == 7) + { + let names: Vec<_> = providers.iter().map(|p| p.descriptor().key).collect(); + let last = *providers.last().unwrap(); + for (state, data, age, sounding, loading, countdown) in states.iter().filter(|state| { + !quick + || matches!( + state.0, + "normal" | "stale" | "alarm-stale" | "waiting" | "countdown" + ) + }) { + let data = if *state == "missing" { + AppUsageData::from_iter( + data.iter() + .filter(|(provider, _)| *provider != last) + .map(|(provider, usage)| (provider, usage.clone())), + ) + } else { + data.clone() + }; + let scales: &[f64] = if quick { + &[1.0, 1.5] + } else { + &[1.0, 1.25, 1.5] + }; + for &scale in scales { + // A 1920 px wide monitor at this scale. + let host_width = (1920.0 / scale) as u32; + for (dark, &language) in [false, true] + .into_iter() + .filter(|dark| providers.len() <= 3 || !dark) + .flat_map(|dark| languages.iter().map(move |language| (dark, language))) + { + let runtime = ThemeRuntime::from_providers(ProviderSet::from_enabled( + providers.iter().copied(), + )) + .with_nest(SurfaceNest::Floating) + .with_host_dimensions(host_width, 1080) + .with_updated_unix(Some(now - age)) + .with_system_dark(dark) + .with_language(language) + .with_alarm(alarm(true, *sounding)) + .with_countdown(*countdown); + let runtime = if *loading { + runtime.with_poll_state(false, false) + } else { + runtime + }; + let label = format!( + "{}/{}/{state}/{}%/{}", + language.code(), + names.join("+"), + scale * 100.0, + if dark { "dark" } else { "light" } + ); + let (width, _) = resolve_surface_size(&theme, 0, Some(&data), runtime); + let squeezed = width >= host_width - 16; + squeezed_bars += squeezed as u32; + if squeezed && providers.len() <= 3 { + problems.push(format!("{label}: squeezed to {width} px")); + } + let (marks, cut) = painted_marks(&theme, &data, runtime, scale); + let physical = |logical: f64| (logical * scale).round() as i32; + // Rounding to device pixels may move an edge by one. + let inner = Mark { + id: "inner".into(), + text: None, + left: physical(EDGE) - 1, + top: physical(6.0) - 1, + right: physical(width as f64 - EDGE) + 1, + bottom: physical(f64::from(BAR_HEIGHT) - 4.0 - 6.0) + 1, + }; + let (crowded, tightest) = crowding(&marks, &inner, physical(2.0)); + // The scan must find what it checks. + let status = marks + .iter() + .find(|mark| mark.id.starts_with("status-") && mark.text.is_some()); + let status_pill = marks + .iter() + .find(|mark| mark.id.starts_with("status-") && mark.id.ends_with("-pill")); + let level_pills = marks + .iter() + .filter(|mark| { + !mark.id.starts_with("status-") && mark.id.ends_with("-pill") + }) + .count(); + let texts = marks.iter().filter(|mark| mark.text.is_some()).count(); + let (Some(status), Some(status_pill)) = (status, status_pill) else { + problems.push(format!("{label}: no status pill found")); + continue; + }; + if matches!( + *state, + "amber" | "red" | "full" | "alarm" | "alarm-stale" | "stale" + ) && level_pills < providers.len() + { + problems.push(format!("{label}: only {level_pills} level pills found")); + } + // The Snooze button, its icon and its text show only + // while the alarm sounds. + let snooze = marks + .iter() + .filter(|mark| mark.id.starts_with("snooze-button")) + .count(); + if snooze != if *sounding { 3 } else { 0 } { + problems.push(format!("{label}: {snooze} Snooze button marks found")); + } + let clear = marks + .iter() + .filter(|mark| !mark.id.starts_with("status-")) + .map(|mark| status_pill.gap(mark)) + .min() + .unwrap(); + println!( + "{label}: {width} px{}, {} marks ({texts} texts, {level_pills} level \ + pills); {status} clears everything by {clear} px; tightest {} at {} px", + if squeezed { " squeezed" } else { "" }, + marks.len(), + tightest.1, + tightest.0 + ); + let cut = if squeezed { Vec::new() } else { cut }; + problems.extend( + crowded + .into_iter() + .chain(cut) + .map(|problem| format!("{label}: {problem}")), + ); + } + } + } + } + (problems, squeezed_bars) +} + +fn assert_no_top_bar_problems((problems, squeezed_bars): (Vec, u32)) { + for problem in &problems { + println!("PROBLEM {problem}"); + } + assert!(squeezed_bars > 0, "no squeezed bar was checked"); + assert!( + problems.is_empty(), + "{} problems, first: {}", + problems.len(), + problems.first().unwrap() + ); +} + +#[test] +fn the_status_words_and_their_order_come_from_the_language() { + let theme = top_bar(); + let data = fixture(true); + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let line = |language, updated: Option, name: &str| { + let runtime = runtime(&[ProviderId::Claude], 1920) + .with_language(language) + .with_updated_unix(updated); + context(&theme, Some(&data), runtime) + .get_string(name) + .unwrap() + .to_string() + }; + let language = |code: &str| { + LanguageId::ALL + .into_iter() + .find(|language| language.code() == code) + .unwrap() + }; + let three_minutes = Some(now - 3 * 60 - 5); + for (language, live, stale, words) in [ + ( + language("en"), + "Live · updated 3m ago", + "Stale · updated 3m ago", + "Waiting for usage data", + ), + ( + language("ja"), + "ライブ · 3分前に更新", + "古い · 3分前に更新", + "使用量を待っています", + ), + ( + language("de"), + "Live · vor 3m aktualisiert", + "Veraltet · vor 3m aktualisiert", + "Warte auf Nutzungsdaten", + ), + ] { + assert_eq!( + line(language, three_minutes, "i18n.status_live_updated"), + live + ); + assert_eq!( + line(language, three_minutes, "i18n.status_stale_updated"), + stale + ); + assert_eq!(line(language, None, "i18n.status_waiting"), words); + // Without a known update time there is no age to show. + assert_eq!(line(language, None, "i18n.status_live_updated"), ""); + } + // Every shipped language words all five lines and keeps the age slot. + for language in LanguageId::ALL { + let strings = language.strings(); + for template in [strings.status_live_updated, strings.status_stale_updated] { + assert_eq!(template.matches("{time}").count(), 1, "{}", language.code()); + } + } +} + +#[test] +fn no_text_or_pill_in_the_top_bar_touches_another_or_leaves_the_card() { + assert_no_top_bar_problems(top_bar_problems(&[LanguageId::English], false)); +} + +#[test] +fn nothing_in_the_top_bar_overlaps_or_is_cut_short_in_any_shipped_language() { + assert_eq!( + LanguageId::ALL.len(), + 14, + "a language was added: measure it" + ); + assert_no_top_bar_problems(top_bar_problems(&LanguageId::ALL, true)); +} + +/// Writes approval screenshots from fixture data, never live credentials: +/// `CCUM_APPROVAL_DIR=approval cargo test approval_screenshots -- --ignored`. +#[test] +#[ignore = "writes PNG files; run on demand"] +fn approval_screenshots() { + let directory = std::path::PathBuf::from( + std::env::var_os("CCUM_APPROVAL_DIR").expect("set CCUM_APPROVAL_DIR"), + ); + std::fs::create_dir_all(&directory).unwrap(); + let theme = top_bar(); + let scale = std::env::var("CCUM_APPROVAL_SCALE") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(1.0); + let pair = [ProviderId::Claude, ProviderId::Codex]; + let three = [ProviderId::Claude, ProviderId::Codex, ProviderId::Cursor]; + // Codex weekly at 93% shows red with two providers, 75% amber with three. + // The alarm shot has Claude's five-hour window at 96% used. + let mut alarming = fixture(true); + alarming.insert( + ProviderId::Claude, + UsageData { + session: section(96.0, Some(HOUR + 12 * 60)), + ..alarming.get(ProviderId::Claude).unwrap().clone() + }, + ); + for (mode, dark) in [("light", false), ("dark", true)] { + let shots: [(String, &[ProviderId], AppUsageData, ThemeAlarm); 3] = [ + ( + format!("topbar-{mode}-2.png"), + &pair, + codex_weekly_at(93.0), + alarm(true, false), + ), + ( + format!("topbar-{mode}-3.png"), + &three, + codex_weekly_at(75.0), + alarm(true, false), + ), + ( + format!("topbar-alarm-{mode}.png"), + &pair, + alarming.clone(), + alarm(true, true), + ), + ]; + for (name, providers, data, alarm) in shots { + let runtime = runtime(providers, 1920) + .with_system_dark(dark) + .with_alarm(alarm); + let rendered = + render_theme_surface_with_runtime_at_scale(&theme, 0, Some(&data), runtime, scale); + assert!(rendered.warnings.is_empty(), "{:?}", rendered.warnings); + save_on_backdrop( + &rendered, + (60.0 * scale) as u32, + dark, + &directory.join(name), + ); + } + let runtime = runtime(&pair, 1920).with_system_dark(dark); + let handle = render_surface_handle( + &theme, + 0, + Some(&codex_weekly_at(75.0)), + runtime, + scale, + SurfaceEdge::Top, + ); + save_on_backdrop( + &handle, + (120.0 * scale) as u32, + dark, + &directory.join(format!("topbar-collapsed-{mode}.png")), + ); + } + // The spot with the longest status: Codex weekly at 93% under + // "Stale · updated 23h 59m ago". + let mut stale = codex_weekly_at(93.0); + let claude = stale.get(ProviderId::Claude).unwrap().clone(); + stale.insert( + ProviderId::Claude, + UsageData { + stale: true, + ..claude + }, + ); + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let runtime = runtime(&pair, 1920) + .with_system_dark(true) + .with_updated_unix(Some(now - (23 * HOUR + 59 * 60 + 30))); + let rendered = + render_theme_surface_with_runtime_at_scale(&theme, 0, Some(&stale), runtime, scale); + assert!(rendered.warnings.is_empty(), "{:?}", rendered.warnings); + save_on_backdrop( + &rendered, + (60.0 * scale) as u32, + true, + &directory.join("topbar-dark-2-stale.png"), + ); +} + +/// Composite premultiplied pixels at the top of a desktop-coloured backdrop +/// for the mode, with a strip of the opposite tone on the right so the edges +/// read true on both. +fn save_on_backdrop(rendered: &RenderedTheme, pad: u32, dark: bool, path: &std::path::Path) { + let (width, height) = (rendered.width + 2 * pad, rendered.height + pad); + let mut image = image::RgbaImage::new(width, height); + let (main, other) = if dark { + ([0x0F, 0x0F, 0x11], [0xF0, 0xF0, 0xF0]) + } else { + ([0xF0, 0xF0, 0xF0], [0x0F, 0x0F, 0x11]) + }; + for (x, y, pixel) in image.enumerate_pixels_mut() { + let backdrop: [u32; 3] = if x < width * 3 / 4 { main } else { other }; + let source = if x >= pad && x < pad + rendered.width && y < rendered.height { + rendered.pixels[(y * rendered.width + x - pad) as usize] + } else { + 0 + }; + let alpha = source >> 24; + let channel = |shift: u32, base: u32| { + ((source >> shift & 0xff) + base * (255 - alpha) / 255).min(255) as u8 + }; + *pixel = image::Rgba([ + channel(16, backdrop[0]), + channel(8, backdrop[1]), + channel(0, backdrop[2]), + 255, + ]); + } + image.save(path).unwrap(); +} diff --git a/src/themes/top-bar.json b/src/themes/top-bar.json new file mode 100644 index 00000000..c0bac487 --- /dev/null +++ b/src/themes/top-bar.json @@ -0,0 +1,3704 @@ +{ + "schema_version": 1, + "id": "top-bar", + "name": "Top Bar", + "surfaces": [ + { + "id": "main", + "name": "Top bar", + "render": "1", + "visibility": "100", + "placement": { + "reference": { + "region": "monitor", + "display": 0 + }, + "nest": "floating", + "horizontal": "center", + "vertical": "top", + "surface_horizontal": "center", + "surface_vertical": "top", + "offset_x": 0, + "offset_y": 0, + "auto_hide": true, + "handle_color": "if(data.loading || data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale), if(system.dark, \"#55555C\", \"#C7C7C7\"), if(max(providers.claude.enabled * claude.five_hour.available * claude.five_hour.percentage, max(providers.claude.enabled * claude.weekly.available * claude.weekly.percentage, max(providers.claude.enabled * claude.model.fable.available * claude.model.fable.percentage, max(providers.codex.enabled * codex.five_hour.available * codex.five_hour.percentage, max(providers.codex.enabled * codex.weekly.available * codex.weekly.percentage, max(providers.antigravity.enabled * antigravity.five_hour.available * antigravity.five_hour.percentage, max(providers.antigravity.enabled * antigravity.weekly.available * antigravity.weekly.percentage, max(providers.copilot.enabled * copilot.five_hour.available * copilot.five_hour.percentage, max(providers.copilot.enabled * copilot.weekly.available * copilot.weekly.percentage, max(providers.cursor.enabled * cursor.five_hour.available * cursor.five_hour.percentage, max(providers.cursor.enabled * cursor.weekly.available * cursor.weekly.percentage, max(providers.grok.enabled * grok.five_hour.available * grok.five_hour.percentage, max(providers.grok.enabled * grok.weekly.available * grok.weekly.percentage, max(providers.opencode.enabled * opencode.five_hour.available * opencode.five_hour.percentage, max(providers.opencode.enabled * opencode.weekly.available * opencode.weekly.percentage, providers.opencode.enabled * opencode.monthly.available * opencode.monthly.percentage))))))))))))))) >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(max(providers.claude.enabled * claude.five_hour.available * claude.five_hour.percentage, max(providers.claude.enabled * claude.weekly.available * claude.weekly.percentage, max(providers.claude.enabled * claude.model.fable.available * claude.model.fable.percentage, max(providers.codex.enabled * codex.five_hour.available * codex.five_hour.percentage, max(providers.codex.enabled * codex.weekly.available * codex.weekly.percentage, max(providers.antigravity.enabled * antigravity.five_hour.available * antigravity.five_hour.percentage, max(providers.antigravity.enabled * antigravity.weekly.available * antigravity.weekly.percentage, max(providers.copilot.enabled * copilot.five_hour.available * copilot.five_hour.percentage, max(providers.copilot.enabled * copilot.weekly.available * copilot.weekly.percentage, max(providers.cursor.enabled * cursor.five_hour.available * cursor.five_hour.percentage, max(providers.cursor.enabled * cursor.weekly.available * cursor.weekly.percentage, max(providers.grok.enabled * grok.five_hour.available * grok.five_hour.percentage, max(providers.grok.enabled * grok.weekly.available * grok.weekly.percentage, max(providers.opencode.enabled * opencode.five_hour.available * opencode.five_hour.percentage, max(providers.opencode.enabled * opencode.weekly.available * opencode.weekly.percentage, providers.opencode.enabled * opencode.monthly.available * opencode.monthly.percentage))))))))))))))) >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#9B9BA2\", \"#6B6B6B\"))))", + "handle_background": "if(system.dark, \"#19191C\", \"#FFFFFF\")", + "force_reveal": "alarm.active" + }, + "width": "min(40 + (providers.count - 1) * 41 + providers.claude.enabled * max(claude.available * ((2 + claude.model.fable.available) * 128 - 16) + (1 - claude.available) * 65, !(providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (255 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)) + providers.codex.enabled * max(codex.available * ((2) * 128 - 16) + (1 - codex.available) * 61, !(providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (251 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)) + providers.antigravity.enabled * max(antigravity.available * ((1 + antigravity.five_hour.available) * 128 - 16) + (1 - antigravity.available) * 92, !(providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (282 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)) + providers.copilot.enabled * max(copilot.available * ((1 + copilot.five_hour.available) * 128 - 16) + (1 - copilot.available) * 68, !(providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (258 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)) + providers.cursor.enabled * max(cursor.available * ((1 + cursor.five_hour.available) * 128 - 16) + (1 - cursor.available) * 63, !(providers.grok.enabled || providers.opencode.enabled) * (253 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)) + providers.grok.enabled * max(grok.available * ((1 + grok.five_hour.available) * 128 - 16) + (1 - grok.available) * 52, !(providers.opencode.enabled) * (242 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)) + providers.opencode.enabled * max(opencode.available * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16) + (1 - opencode.available) * 87, 1 * (277 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102)), max(160, host.width - 16))", + "height": "84", + "background": { + "type": "colour", + "colour": { + "color": "#00000000", + "opacity": "1" + } + }, + "border": null, + "mouse_events": { + "double_click": "show_dashboard()", + "right_click": "show_context_menu(\"classic-v1\")" + }, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + }, + "children": [ + { + "id": "drop-2", + "name": "Drop 2", + "x": "3", + "y": "-16", + "width": "canvas.width - 6", + "height": "98", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#00000066\", \"#28282814\")", + "opacity": "0.5" + } + }, + "corner_radius": "17" + }, + { + "id": "drop-1", + "name": "Drop 1", + "x": "4", + "y": "-16", + "width": "canvas.width - 8", + "height": "97", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#00000066\", \"#28282814\")", + "opacity": "1" + } + }, + "corner_radius": "16" + }, + { + "id": "card", + "name": "Card", + "x": "4", + "y": "-16", + "width": "canvas.width - 8", + "height": "96", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#19191C\", \"#FFFFFF\")", + "opacity": "1" + } + }, + "border": { + "color": { + "color": "if(system.dark, \"#FFFFFF14\", \"#ECECEC\")", + "opacity": "1" + }, + "width": "1" + }, + "corner_radius": "16" + }, + { + "id": "providers", + "name": "Enabled CLIs", + "x": "20", + "width": "canvas.width - 40", + "height": "80", + "layout": "row", + "gap": "20 * clamp(min((canvas.width - (40 + (providers.count - 1) + providers.claude.enabled * (1 - claude.available) * 65 + providers.codex.enabled * (1 - codex.available) * 61 + providers.antigravity.enabled * (1 - antigravity.available) * 92 + providers.copilot.enabled * (1 - copilot.available) * 68 + providers.cursor.enabled * (1 - cursor.available) * 63 + providers.grok.enabled * (1 - grok.available) * 52 + providers.opencode.enabled * (1 - opencode.available) * 87)) / max(1, (providers.count - 1) * 40 + providers.claude.enabled * claude.available * ((2 + claude.model.fable.available) * 128 - 16) + providers.codex.enabled * codex.available * ((2) * 128 - 16) + providers.antigravity.enabled * antigravity.available * ((1 + antigravity.five_hour.available) * 128 - 16) + providers.copilot.enabled * copilot.available * ((1 + copilot.five_hour.available) * 128 - 16) + providers.cursor.enabled * cursor.available * ((1 + cursor.five_hour.available) * 128 - 16) + providers.grok.enabled * grok.available * ((1 + grok.five_hour.available) * 128 - 16) + providers.opencode.enabled * opencode.available * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16)), if(((providers.count - 1) * 40 + providers.claude.enabled * claude.available * ((2 + claude.model.fable.available) * 128 - 16) + providers.codex.enabled * codex.available * ((2) * 128 - 16) + providers.antigravity.enabled * antigravity.available * ((1 + antigravity.five_hour.available) * 128 - 16) + providers.copilot.enabled * copilot.available * ((1 + copilot.five_hour.available) * 128 - 16) + providers.cursor.enabled * cursor.available * ((1 + cursor.five_hour.available) * 128 - 16) + providers.grok.enabled * grok.available * ((1 + grok.five_hour.available) * 128 - 16) + providers.opencode.enabled * opencode.available * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16)) - (providers.claude.enabled * !(providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * claude.available * ((2 + claude.model.fable.available) * 128 - 16) + providers.codex.enabled * !(providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * codex.available * ((2) * 128 - 16) + providers.antigravity.enabled * !(providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * antigravity.available * ((1 + antigravity.five_hour.available) * 128 - 16) + providers.copilot.enabled * !(providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * copilot.available * ((1 + copilot.five_hour.available) * 128 - 16) + providers.cursor.enabled * !(providers.grok.enabled || providers.opencode.enabled) * cursor.available * ((1 + cursor.five_hour.available) * 128 - 16) + providers.grok.enabled * !(providers.opencode.enabled) * grok.available * ((1 + grok.five_hour.available) * 128 - 16) + providers.opencode.enabled * 1 * opencode.available * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16)) > 0, (canvas.width - (40 + (providers.count - 1) + providers.claude.enabled * (1 - claude.available) * 65 + providers.codex.enabled * (1 - codex.available) * 61 + providers.antigravity.enabled * (1 - antigravity.available) * 92 + providers.copilot.enabled * (1 - copilot.available) * 68 + providers.cursor.enabled * (1 - cursor.available) * 63 + providers.grok.enabled * (1 - grok.available) * 52 + providers.opencode.enabled * (1 - opencode.available) * 87) - (providers.claude.enabled * !(providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * ((255 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - claude.available) * 65) + providers.codex.enabled * !(providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * ((251 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - codex.available) * 61) + providers.antigravity.enabled * !(providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * ((282 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - antigravity.available) * 92) + providers.copilot.enabled * !(providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * ((258 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - copilot.available) * 68) + providers.cursor.enabled * !(providers.grok.enabled || providers.opencode.enabled) * ((253 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - cursor.available) * 63) + providers.grok.enabled * !(providers.opencode.enabled) * ((242 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - grok.available) * 52) + providers.opencode.enabled * 1 * ((277 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102) - (1 - opencode.available) * 87))) / max(1, ((providers.count - 1) * 40 + providers.claude.enabled * claude.available * ((2 + claude.model.fable.available) * 128 - 16) + providers.codex.enabled * codex.available * ((2) * 128 - 16) + providers.antigravity.enabled * antigravity.available * ((1 + antigravity.five_hour.available) * 128 - 16) + providers.copilot.enabled * copilot.available * ((1 + copilot.five_hour.available) * 128 - 16) + providers.cursor.enabled * cursor.available * ((1 + cursor.five_hour.available) * 128 - 16) + providers.grok.enabled * grok.available * ((1 + grok.five_hour.available) * 128 - 16) + providers.opencode.enabled * opencode.available * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16)) - (providers.claude.enabled * !(providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * claude.available * ((2 + claude.model.fable.available) * 128 - 16) + providers.codex.enabled * !(providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * codex.available * ((2) * 128 - 16) + providers.antigravity.enabled * !(providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * antigravity.available * ((1 + antigravity.five_hour.available) * 128 - 16) + providers.copilot.enabled * !(providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * copilot.available * ((1 + copilot.five_hour.available) * 128 - 16) + providers.cursor.enabled * !(providers.grok.enabled || providers.opencode.enabled) * cursor.available * ((1 + cursor.five_hour.available) * 128 - 16) + providers.grok.enabled * !(providers.opencode.enabled) * grok.available * ((1 + grok.five_hour.available) * 128 - 16) + providers.opencode.enabled * 1 * opencode.available * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16))), 1)), 0.2, 1)" + }, + { + "id": "claude-block", + "name": "Claude block", + "render": "providers.claude.enabled", + "parent": "providers", + "width": "max(claude.available * parent.gap / 20 * ((2 + claude.model.fable.available) * 128 - 16) + (1 - claude.available) * 65, !(providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (255 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "claude-halo", + "name": "Claude halo", + "parent": "claude-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#D97757", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "claude-dot", + "name": "Claude dot", + "parent": "claude-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#D97757", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "claude-name", + "name": "Claude name", + "parent": "claude-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "Claude", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "claude-na", + "name": "Claude unavailable", + "render": "!claude.available", + "parent": "claude-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "claude-cells", + "name": "Claude limits", + "render": "claude.available", + "parent": "claude-block", + "y": "30", + "width": "parent.gap / 20 * ((2 + claude.model.fable.available) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (2 + claude.model.fable.available) * 128 - 16)" + }, + { + "id": "claude-five_hour", + "name": "Claude five_hour", + "parent": "claude-cells", + "width": "(parent.width - ((2 + claude.model.fable.available) - 1) * parent.gap) / max(1, 2 + claude.model.fable.available)", + "height": "42" + }, + { + "id": "claude-five_hour-label", + "name": "Claude five_hour label", + "visibility": "if(parent.width - if(claude.five_hour.available, ((max(0, round(claude.five_hour.display))) >= 100) * if(floor((max(0, round(claude.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.five_hour.display))) >= 10) * if(floor((max(0, round(claude.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(claude.five_hour.percentage >= 70 || claude.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "claude-five_hour", + "width": "parent.width - if(claude.five_hour.available, ((max(0, round(claude.five_hour.display))) >= 100) * if(floor((max(0, round(claude.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.five_hour.display))) >= 10) * if(floor((max(0, round(claude.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(claude.five_hour.percentage >= 70 || claude.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "5-hour", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(claude.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(claude.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "claude-five_hour-pill", + "name": "Claude five_hour level pill", + "render": "claude.five_hour.available && (claude.five_hour.percentage >= 70 || claude.five_hour.alarm)", + "parent": "claude-five_hour", + "x": "parent.width - (((max(0, round(claude.five_hour.display))) >= 100) * if(floor((max(0, round(claude.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.five_hour.display))) >= 10) * if(floor((max(0, round(claude.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(claude.five_hour.display))) >= 100) * if(floor((max(0, round(claude.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.five_hour.display))) >= 10) * if(floor((max(0, round(claude.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(claude.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(claude.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "claude-five_hour-value", + "name": "Claude five_hour value", + "render": "claude.five_hour.available", + "parent": "claude-five_hour", + "width": "parent.width - if(claude.five_hour.percentage >= 70 || claude.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{claude.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(claude.five_hour.alarm, \"#FFFFFF\", if(claude.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(claude.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "claude-five_hour-na", + "name": "Claude five_hour unavailable", + "render": "!claude.five_hour.available", + "parent": "claude-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "claude-five_hour-track", + "name": "Claude five_hour track", + "parent": "claude-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(claude.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "claude-five_hour-fill", + "name": "Claude five_hour fill", + "render": "claude.five_hour.available && claude.five_hour.display > 0", + "parent": "claude-five_hour", + "y": "21", + "width": "max(4, parent.width * min(claude.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(claude.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "claude-five_hour-resets", + "name": "Claude five_hour resets in", + "render": "claude.five_hour.available && claude.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(claude.five_hour.reset.seconds < 60, 23, if(claude.five_hour.reset.seconds < 3600, ((floor(claude.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(claude.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(claude.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(claude.five_hour.reset.seconds < 86400, ((floor(claude.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(claude.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(claude.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(claude.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(claude.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(claude.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(claude.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(claude.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(claude.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(claude.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(claude.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(claude.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "claude-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "claude-five_hour-countdown", + "name": "Claude five_hour countdown", + "render": "claude.five_hour.available && claude.five_hour.reset.unix > 0", + "parent": "claude-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{claude.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "claude-weekly", + "name": "Claude weekly", + "parent": "claude-cells", + "width": "(parent.width - ((2 + claude.model.fable.available) - 1) * parent.gap) / max(1, 2 + claude.model.fable.available)", + "height": "42" + }, + { + "id": "claude-weekly-label", + "name": "Claude weekly label", + "visibility": "if(parent.width - if(claude.weekly.available, ((max(0, round(claude.weekly.display))) >= 100) * if(floor((max(0, round(claude.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.weekly.display))) >= 10) * if(floor((max(0, round(claude.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.weekly.display))) % 10 == 1, 6, 8) + 12 + if(claude.weekly.percentage >= 70 || claude.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "claude-weekly", + "width": "parent.width - if(claude.weekly.available, ((max(0, round(claude.weekly.display))) >= 100) * if(floor((max(0, round(claude.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.weekly.display))) >= 10) * if(floor((max(0, round(claude.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.weekly.display))) % 10 == 1, 6, 8) + 12 + if(claude.weekly.percentage >= 70 || claude.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "Weekly", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(claude.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(claude.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "claude-weekly-pill", + "name": "Claude weekly level pill", + "render": "claude.weekly.available && (claude.weekly.percentage >= 70 || claude.weekly.alarm)", + "parent": "claude-weekly", + "x": "parent.width - (((max(0, round(claude.weekly.display))) >= 100) * if(floor((max(0, round(claude.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.weekly.display))) >= 10) * if(floor((max(0, round(claude.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(claude.weekly.display))) >= 100) * if(floor((max(0, round(claude.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.weekly.display))) >= 10) * if(floor((max(0, round(claude.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(claude.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(claude.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "claude-weekly-value", + "name": "Claude weekly value", + "render": "claude.weekly.available", + "parent": "claude-weekly", + "width": "parent.width - if(claude.weekly.percentage >= 70 || claude.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{claude.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(claude.weekly.alarm, \"#FFFFFF\", if(claude.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(claude.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "claude-weekly-na", + "name": "Claude weekly unavailable", + "render": "!claude.weekly.available", + "parent": "claude-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "claude-weekly-track", + "name": "Claude weekly track", + "parent": "claude-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(claude.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "claude-weekly-fill", + "name": "Claude weekly fill", + "render": "claude.weekly.available && claude.weekly.display > 0", + "parent": "claude-weekly", + "y": "21", + "width": "max(4, parent.width * min(claude.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(claude.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "claude-weekly-resets", + "name": "Claude weekly resets in", + "render": "claude.weekly.available && claude.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(claude.weekly.reset.seconds < 60, 23, if(claude.weekly.reset.seconds < 3600, ((floor(claude.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(claude.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(claude.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(claude.weekly.reset.seconds < 86400, ((floor(claude.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(claude.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(claude.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(claude.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(claude.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(claude.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(claude.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(claude.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(claude.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(claude.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(claude.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(claude.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "claude-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "claude-weekly-countdown", + "name": "Claude weekly countdown", + "render": "claude.weekly.available && claude.weekly.reset.unix > 0", + "parent": "claude-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{claude.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "claude-fable", + "name": "Claude fable", + "render": "claude.model.fable.available", + "parent": "claude-cells", + "width": "(parent.width - ((2 + claude.model.fable.available) - 1) * parent.gap) / max(1, 2 + claude.model.fable.available)", + "height": "42" + }, + { + "id": "claude-fable-label", + "name": "Claude fable label", + "visibility": "if(parent.width - if(claude.model.fable.available, ((max(0, round(claude.model.fable.display))) >= 100) * if(floor((max(0, round(claude.model.fable.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.model.fable.display))) >= 10) * if(floor((max(0, round(claude.model.fable.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.model.fable.display))) % 10 == 1, 6, 8) + 12 + if(claude.model.fable.percentage >= 70 || claude.model.fable.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "claude-fable", + "width": "parent.width - if(claude.model.fable.available, ((max(0, round(claude.model.fable.display))) >= 100) * if(floor((max(0, round(claude.model.fable.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.model.fable.display))) >= 10) * if(floor((max(0, round(claude.model.fable.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.model.fable.display))) % 10 == 1, 6, 8) + 12 + if(claude.model.fable.percentage >= 70 || claude.model.fable.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "Fable", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(claude.model.fable.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(claude.model.fable.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "claude-fable-label-scope", + "name": "Claude fable scope", + "visibility": "if(parent.width - if(claude.model.fable.available, ((max(0, round(claude.model.fable.display))) >= 100) * if(floor((max(0, round(claude.model.fable.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.model.fable.display))) >= 10) * if(floor((max(0, round(claude.model.fable.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.model.fable.display))) % 10 == 1, 6, 8) + 12 + if(claude.model.fable.percentage >= 70 || claude.model.fable.alarm, 14, 0), 22) - 6 >= 31 + ((36) * 1.045 + 1), 100, 0)", + "parent": "claude-fable", + "x": "31", + "width": "parent.width - if(claude.model.fable.available, ((max(0, round(claude.model.fable.display))) >= 100) * if(floor((max(0, round(claude.model.fable.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.model.fable.display))) >= 10) * if(floor((max(0, round(claude.model.fable.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.model.fable.display))) % 10 == 1, 6, 8) + 12 + if(claude.model.fable.percentage >= 70 || claude.model.fable.alarm, 14, 0), 22) - 6 - 31", + "height": "18", + "content": { + "type": "text", + "template": "weekly", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "claude-fable-pill", + "name": "Claude fable level pill", + "render": "claude.model.fable.available && (claude.model.fable.percentage >= 70 || claude.model.fable.alarm)", + "parent": "claude-fable", + "x": "parent.width - (((max(0, round(claude.model.fable.display))) >= 100) * if(floor((max(0, round(claude.model.fable.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.model.fable.display))) >= 10) * if(floor((max(0, round(claude.model.fable.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.model.fable.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(claude.model.fable.display))) >= 100) * if(floor((max(0, round(claude.model.fable.display))) / 100) == 1, 6, 8) + ((max(0, round(claude.model.fable.display))) >= 10) * if(floor((max(0, round(claude.model.fable.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(claude.model.fable.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(claude.model.fable.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.model.fable.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(claude.model.fable.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "claude-fable-value", + "name": "Claude fable value", + "render": "claude.model.fable.available", + "parent": "claude-fable", + "width": "parent.width - if(claude.model.fable.percentage >= 70 || claude.model.fable.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{claude.model.fable.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(claude.model.fable.alarm, \"#FFFFFF\", if(claude.model.fable.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(claude.model.fable.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "claude-fable-na", + "name": "Claude fable unavailable", + "render": "!claude.model.fable.available", + "parent": "claude-fable", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "claude-fable-track", + "name": "Claude fable track", + "parent": "claude-fable", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(claude.model.fable.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "claude-fable-fill", + "name": "Claude fable fill", + "render": "claude.model.fable.available && claude.model.fable.display > 0", + "parent": "claude-fable", + "y": "21", + "width": "max(4, parent.width * min(claude.model.fable.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(claude.model.fable.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.model.fable.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(claude.model.fable.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "claude-fable-resets", + "name": "Claude fable resets in", + "render": "claude.model.fable.available && claude.model.fable.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(claude.model.fable.reset.seconds < 60, 23, if(claude.model.fable.reset.seconds < 3600, ((floor(claude.model.fable.reset.seconds / 60)) >= 10) * if(floor((floor(claude.model.fable.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(claude.model.fable.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(claude.model.fable.reset.seconds < 86400, ((floor(claude.model.fable.reset.seconds / 3600)) >= 10) * if(floor((floor(claude.model.fable.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(claude.model.fable.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(claude.model.fable.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(claude.model.fable.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(claude.model.fable.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(claude.model.fable.reset.seconds / 86400)) >= 10) * if(floor((floor(claude.model.fable.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(claude.model.fable.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(claude.model.fable.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(claude.model.fable.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(claude.model.fable.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "claude-fable", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "claude-fable-countdown", + "name": "Claude fable countdown", + "render": "claude.model.fable.available && claude.model.fable.reset.unix > 0", + "parent": "claude-fable", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{claude.model.fable.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "codex-divider", + "name": "Codex divider", + "render": "providers.codex.enabled && (providers.claude.enabled)", + "parent": "providers", + "y": "9", + "width": "1", + "height": "63", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "1" + } + } + }, + { + "id": "codex-block", + "name": "Codex block", + "render": "providers.codex.enabled", + "parent": "providers", + "width": "max(codex.available * parent.gap / 20 * ((2) * 128 - 16) + (1 - codex.available) * 61, !(providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (251 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "codex-halo", + "name": "Codex halo", + "parent": "codex-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#5B5FD6", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "codex-dot", + "name": "Codex dot", + "parent": "codex-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#5B5FD6", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "codex-name", + "name": "Codex name", + "parent": "codex-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "Codex", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "codex-na", + "name": "Codex unavailable", + "render": "!codex.available", + "parent": "codex-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "codex-cells", + "name": "Codex limits", + "render": "codex.available", + "parent": "codex-block", + "y": "30", + "width": "parent.gap / 20 * ((2) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (2) * 128 - 16)" + }, + { + "id": "codex-five_hour", + "name": "Codex five_hour", + "parent": "codex-cells", + "width": "(parent.width - ((2) - 1) * parent.gap) / max(1, 2)", + "height": "42" + }, + { + "id": "codex-five_hour-label", + "name": "Codex five_hour label", + "visibility": "if(parent.width - if(codex.five_hour.available, ((max(0, round(codex.five_hour.display))) >= 100) * if(floor((max(0, round(codex.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.five_hour.display))) >= 10) * if(floor((max(0, round(codex.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(codex.five_hour.percentage >= 70 || codex.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "codex-five_hour", + "width": "parent.width - if(codex.five_hour.available, ((max(0, round(codex.five_hour.display))) >= 100) * if(floor((max(0, round(codex.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.five_hour.display))) >= 10) * if(floor((max(0, round(codex.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(codex.five_hour.percentage >= 70 || codex.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "5-hour", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(codex.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(codex.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "codex-five_hour-pill", + "name": "Codex five_hour level pill", + "render": "codex.five_hour.available && (codex.five_hour.percentage >= 70 || codex.five_hour.alarm)", + "parent": "codex-five_hour", + "x": "parent.width - (((max(0, round(codex.five_hour.display))) >= 100) * if(floor((max(0, round(codex.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.five_hour.display))) >= 10) * if(floor((max(0, round(codex.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(codex.five_hour.display))) >= 100) * if(floor((max(0, round(codex.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.five_hour.display))) >= 10) * if(floor((max(0, round(codex.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(codex.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(codex.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(codex.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "codex-five_hour-value", + "name": "Codex five_hour value", + "render": "codex.five_hour.available", + "parent": "codex-five_hour", + "width": "parent.width - if(codex.five_hour.percentage >= 70 || codex.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{codex.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(codex.five_hour.alarm, \"#FFFFFF\", if(codex.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(codex.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "codex-five_hour-na", + "name": "Codex five_hour unavailable", + "render": "!codex.five_hour.available", + "parent": "codex-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "codex-five_hour-track", + "name": "Codex five_hour track", + "parent": "codex-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(codex.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "codex-five_hour-fill", + "name": "Codex five_hour fill", + "render": "codex.five_hour.available && codex.five_hour.display > 0", + "parent": "codex-five_hour", + "y": "21", + "width": "max(4, parent.width * min(codex.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(codex.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(codex.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(codex.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "codex-five_hour-resets", + "name": "Codex five_hour resets in", + "render": "codex.five_hour.available && codex.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(codex.five_hour.reset.seconds < 60, 23, if(codex.five_hour.reset.seconds < 3600, ((floor(codex.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(codex.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(codex.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(codex.five_hour.reset.seconds < 86400, ((floor(codex.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(codex.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(codex.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(codex.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(codex.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(codex.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(codex.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(codex.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(codex.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(codex.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(codex.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(codex.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "codex-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "codex-five_hour-countdown", + "name": "Codex five_hour countdown", + "render": "codex.five_hour.available && codex.five_hour.reset.unix > 0", + "parent": "codex-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{codex.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "codex-weekly", + "name": "Codex weekly", + "parent": "codex-cells", + "width": "(parent.width - ((2) - 1) * parent.gap) / max(1, 2)", + "height": "42" + }, + { + "id": "codex-weekly-label", + "name": "Codex weekly label", + "visibility": "if(parent.width - if(codex.weekly.available, ((max(0, round(codex.weekly.display))) >= 100) * if(floor((max(0, round(codex.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.weekly.display))) >= 10) * if(floor((max(0, round(codex.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.weekly.display))) % 10 == 1, 6, 8) + 12 + if(codex.weekly.percentage >= 70 || codex.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "codex-weekly", + "width": "parent.width - if(codex.weekly.available, ((max(0, round(codex.weekly.display))) >= 100) * if(floor((max(0, round(codex.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.weekly.display))) >= 10) * if(floor((max(0, round(codex.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.weekly.display))) % 10 == 1, 6, 8) + 12 + if(codex.weekly.percentage >= 70 || codex.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "{if(codex.weekly.label == \"30d\", \"Monthly\", if(codex.weekly.label == \"API\", \"API\", \"Weekly\"))}", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(codex.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(codex.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "codex-weekly-pill", + "name": "Codex weekly level pill", + "render": "codex.weekly.available && (codex.weekly.percentage >= 70 || codex.weekly.alarm)", + "parent": "codex-weekly", + "x": "parent.width - (((max(0, round(codex.weekly.display))) >= 100) * if(floor((max(0, round(codex.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.weekly.display))) >= 10) * if(floor((max(0, round(codex.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(codex.weekly.display))) >= 100) * if(floor((max(0, round(codex.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(codex.weekly.display))) >= 10) * if(floor((max(0, round(codex.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(codex.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(codex.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(codex.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(codex.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "codex-weekly-value", + "name": "Codex weekly value", + "render": "codex.weekly.available", + "parent": "codex-weekly", + "width": "parent.width - if(codex.weekly.percentage >= 70 || codex.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{codex.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(codex.weekly.alarm, \"#FFFFFF\", if(codex.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(codex.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "codex-weekly-na", + "name": "Codex weekly unavailable", + "render": "!codex.weekly.available", + "parent": "codex-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "codex-weekly-track", + "name": "Codex weekly track", + "parent": "codex-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(codex.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "codex-weekly-fill", + "name": "Codex weekly fill", + "render": "codex.weekly.available && codex.weekly.display > 0", + "parent": "codex-weekly", + "y": "21", + "width": "max(4, parent.width * min(codex.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(codex.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(codex.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(codex.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "codex-weekly-resets", + "name": "Codex weekly resets in", + "render": "codex.weekly.available && codex.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(codex.weekly.reset.seconds < 60, 23, if(codex.weekly.reset.seconds < 3600, ((floor(codex.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(codex.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(codex.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(codex.weekly.reset.seconds < 86400, ((floor(codex.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(codex.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(codex.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(codex.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(codex.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(codex.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(codex.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(codex.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(codex.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(codex.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(codex.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(codex.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "codex-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "codex-weekly-countdown", + "name": "Codex weekly countdown", + "render": "codex.weekly.available && codex.weekly.reset.unix > 0", + "parent": "codex-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{codex.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-divider", + "name": "Antigravity divider", + "render": "providers.antigravity.enabled && (providers.claude.enabled || providers.codex.enabled)", + "parent": "providers", + "y": "9", + "width": "1", + "height": "63", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-block", + "name": "Antigravity block", + "render": "providers.antigravity.enabled", + "parent": "providers", + "width": "max(antigravity.available * parent.gap / 20 * ((1 + antigravity.five_hour.available) * 128 - 16) + (1 - antigravity.available) * 92, !(providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (282 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "antigravity-halo", + "name": "Antigravity halo", + "parent": "antigravity-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#14A3A0", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "antigravity-dot", + "name": "Antigravity dot", + "parent": "antigravity-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#14A3A0", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "antigravity-name", + "name": "Antigravity name", + "parent": "antigravity-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "Antigravity", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-na", + "name": "Antigravity unavailable", + "render": "!antigravity.available", + "parent": "antigravity-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-cells", + "name": "Antigravity limits", + "render": "antigravity.available", + "parent": "antigravity-block", + "y": "30", + "width": "parent.gap / 20 * ((1 + antigravity.five_hour.available) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (1 + antigravity.five_hour.available) * 128 - 16)" + }, + { + "id": "antigravity-five_hour", + "name": "Antigravity five_hour", + "render": "antigravity.five_hour.available", + "parent": "antigravity-cells", + "width": "(parent.width - ((1 + antigravity.five_hour.available) - 1) * parent.gap) / max(1, 1 + antigravity.five_hour.available)", + "height": "42" + }, + { + "id": "antigravity-five_hour-label", + "name": "Antigravity five_hour label", + "visibility": "if(parent.width - if(antigravity.five_hour.available, ((max(0, round(antigravity.five_hour.display))) >= 100) * if(floor((max(0, round(antigravity.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.five_hour.display))) >= 10) * if(floor((max(0, round(antigravity.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(antigravity.five_hour.percentage >= 70 || antigravity.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "antigravity-five_hour", + "width": "parent.width - if(antigravity.five_hour.available, ((max(0, round(antigravity.five_hour.display))) >= 100) * if(floor((max(0, round(antigravity.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.five_hour.display))) >= 10) * if(floor((max(0, round(antigravity.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(antigravity.five_hour.percentage >= 70 || antigravity.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "5-hour", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(antigravity.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(antigravity.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "antigravity-five_hour-pill", + "name": "Antigravity five_hour level pill", + "render": "antigravity.five_hour.available && (antigravity.five_hour.percentage >= 70 || antigravity.five_hour.alarm)", + "parent": "antigravity-five_hour", + "x": "parent.width - (((max(0, round(antigravity.five_hour.display))) >= 100) * if(floor((max(0, round(antigravity.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.five_hour.display))) >= 10) * if(floor((max(0, round(antigravity.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(antigravity.five_hour.display))) >= 100) * if(floor((max(0, round(antigravity.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.five_hour.display))) >= 10) * if(floor((max(0, round(antigravity.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(antigravity.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(antigravity.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(antigravity.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "antigravity-five_hour-value", + "name": "Antigravity five_hour value", + "render": "antigravity.five_hour.available", + "parent": "antigravity-five_hour", + "width": "parent.width - if(antigravity.five_hour.percentage >= 70 || antigravity.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{antigravity.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(antigravity.five_hour.alarm, \"#FFFFFF\", if(antigravity.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(antigravity.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "antigravity-five_hour-na", + "name": "Antigravity five_hour unavailable", + "render": "!antigravity.five_hour.available", + "parent": "antigravity-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-five_hour-track", + "name": "Antigravity five_hour track", + "parent": "antigravity-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(antigravity.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "antigravity-five_hour-fill", + "name": "Antigravity five_hour fill", + "render": "antigravity.five_hour.available && antigravity.five_hour.display > 0", + "parent": "antigravity-five_hour", + "y": "21", + "width": "max(4, parent.width * min(antigravity.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(antigravity.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(antigravity.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(antigravity.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "antigravity-five_hour-resets", + "name": "Antigravity five_hour resets in", + "render": "antigravity.five_hour.available && antigravity.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(antigravity.five_hour.reset.seconds < 60, 23, if(antigravity.five_hour.reset.seconds < 3600, ((floor(antigravity.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(antigravity.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(antigravity.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(antigravity.five_hour.reset.seconds < 86400, ((floor(antigravity.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(antigravity.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(antigravity.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(antigravity.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(antigravity.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(antigravity.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(antigravity.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(antigravity.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(antigravity.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(antigravity.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(antigravity.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(antigravity.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "antigravity-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-five_hour-countdown", + "name": "Antigravity five_hour countdown", + "render": "antigravity.five_hour.available && antigravity.five_hour.reset.unix > 0", + "parent": "antigravity-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{antigravity.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-weekly", + "name": "Antigravity weekly", + "parent": "antigravity-cells", + "width": "(parent.width - ((1 + antigravity.five_hour.available) - 1) * parent.gap) / max(1, 1 + antigravity.five_hour.available)", + "height": "42" + }, + { + "id": "antigravity-weekly-label", + "name": "Antigravity weekly label", + "visibility": "if(parent.width - if(antigravity.weekly.available, ((max(0, round(antigravity.weekly.display))) >= 100) * if(floor((max(0, round(antigravity.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.weekly.display))) >= 10) * if(floor((max(0, round(antigravity.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.weekly.display))) % 10 == 1, 6, 8) + 12 + if(antigravity.weekly.percentage >= 70 || antigravity.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "antigravity-weekly", + "width": "parent.width - if(antigravity.weekly.available, ((max(0, round(antigravity.weekly.display))) >= 100) * if(floor((max(0, round(antigravity.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.weekly.display))) >= 10) * if(floor((max(0, round(antigravity.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.weekly.display))) % 10 == 1, 6, 8) + 12 + if(antigravity.weekly.percentage >= 70 || antigravity.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "{if(antigravity.weekly.label == \"30d\", \"Monthly\", if(antigravity.weekly.label == \"API\", \"API\", \"Weekly\"))}", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(antigravity.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(antigravity.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "antigravity-weekly-pill", + "name": "Antigravity weekly level pill", + "render": "antigravity.weekly.available && (antigravity.weekly.percentage >= 70 || antigravity.weekly.alarm)", + "parent": "antigravity-weekly", + "x": "parent.width - (((max(0, round(antigravity.weekly.display))) >= 100) * if(floor((max(0, round(antigravity.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.weekly.display))) >= 10) * if(floor((max(0, round(antigravity.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(antigravity.weekly.display))) >= 100) * if(floor((max(0, round(antigravity.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(antigravity.weekly.display))) >= 10) * if(floor((max(0, round(antigravity.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(antigravity.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(antigravity.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(antigravity.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(antigravity.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "antigravity-weekly-value", + "name": "Antigravity weekly value", + "render": "antigravity.weekly.available", + "parent": "antigravity-weekly", + "width": "parent.width - if(antigravity.weekly.percentage >= 70 || antigravity.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{antigravity.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(antigravity.weekly.alarm, \"#FFFFFF\", if(antigravity.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(antigravity.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "antigravity-weekly-na", + "name": "Antigravity weekly unavailable", + "render": "!antigravity.weekly.available", + "parent": "antigravity-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-weekly-track", + "name": "Antigravity weekly track", + "parent": "antigravity-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(antigravity.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "antigravity-weekly-fill", + "name": "Antigravity weekly fill", + "render": "antigravity.weekly.available && antigravity.weekly.display > 0", + "parent": "antigravity-weekly", + "y": "21", + "width": "max(4, parent.width * min(antigravity.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(antigravity.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(antigravity.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(antigravity.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "antigravity-weekly-resets", + "name": "Antigravity weekly resets in", + "render": "antigravity.weekly.available && antigravity.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(antigravity.weekly.reset.seconds < 60, 23, if(antigravity.weekly.reset.seconds < 3600, ((floor(antigravity.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(antigravity.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(antigravity.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(antigravity.weekly.reset.seconds < 86400, ((floor(antigravity.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(antigravity.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(antigravity.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(antigravity.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(antigravity.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(antigravity.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(antigravity.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(antigravity.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(antigravity.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(antigravity.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(antigravity.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(antigravity.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "antigravity-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "antigravity-weekly-countdown", + "name": "Antigravity weekly countdown", + "render": "antigravity.weekly.available && antigravity.weekly.reset.unix > 0", + "parent": "antigravity-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{antigravity.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-divider", + "name": "Copilot divider", + "render": "providers.copilot.enabled && (providers.claude.enabled || providers.codex.enabled || providers.antigravity.enabled)", + "parent": "providers", + "y": "9", + "width": "1", + "height": "63", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-block", + "name": "Copilot block", + "render": "providers.copilot.enabled", + "parent": "providers", + "width": "max(copilot.available * parent.gap / 20 * ((1 + copilot.five_hour.available) * 128 - 16) + (1 - copilot.available) * 68, !(providers.cursor.enabled || providers.grok.enabled || providers.opencode.enabled) * (258 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "copilot-halo", + "name": "Copilot halo", + "parent": "copilot-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#8B5CF6", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "copilot-dot", + "name": "Copilot dot", + "parent": "copilot-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#8B5CF6", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "copilot-name", + "name": "Copilot name", + "parent": "copilot-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "Copilot", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-na", + "name": "Copilot unavailable", + "render": "!copilot.available", + "parent": "copilot-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-cells", + "name": "Copilot limits", + "render": "copilot.available", + "parent": "copilot-block", + "y": "30", + "width": "parent.gap / 20 * ((1 + copilot.five_hour.available) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (1 + copilot.five_hour.available) * 128 - 16)" + }, + { + "id": "copilot-five_hour", + "name": "Copilot five_hour", + "render": "copilot.five_hour.available", + "parent": "copilot-cells", + "width": "(parent.width - ((1 + copilot.five_hour.available) - 1) * parent.gap) / max(1, 1 + copilot.five_hour.available)", + "height": "42" + }, + { + "id": "copilot-five_hour-label", + "name": "Copilot five_hour label", + "visibility": "if(parent.width - if(copilot.five_hour.available, ((max(0, round(copilot.five_hour.display))) >= 100) * if(floor((max(0, round(copilot.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.five_hour.display))) >= 10) * if(floor((max(0, round(copilot.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(copilot.five_hour.percentage >= 70 || copilot.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "copilot-five_hour", + "width": "parent.width - if(copilot.five_hour.available, ((max(0, round(copilot.five_hour.display))) >= 100) * if(floor((max(0, round(copilot.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.five_hour.display))) >= 10) * if(floor((max(0, round(copilot.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(copilot.five_hour.percentage >= 70 || copilot.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "5-hour", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(copilot.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(copilot.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "copilot-five_hour-pill", + "name": "Copilot five_hour level pill", + "render": "copilot.five_hour.available && (copilot.five_hour.percentage >= 70 || copilot.five_hour.alarm)", + "parent": "copilot-five_hour", + "x": "parent.width - (((max(0, round(copilot.five_hour.display))) >= 100) * if(floor((max(0, round(copilot.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.five_hour.display))) >= 10) * if(floor((max(0, round(copilot.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(copilot.five_hour.display))) >= 100) * if(floor((max(0, round(copilot.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.five_hour.display))) >= 10) * if(floor((max(0, round(copilot.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(copilot.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(copilot.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(copilot.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "copilot-five_hour-value", + "name": "Copilot five_hour value", + "render": "copilot.five_hour.available", + "parent": "copilot-five_hour", + "width": "parent.width - if(copilot.five_hour.percentage >= 70 || copilot.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{copilot.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(copilot.five_hour.alarm, \"#FFFFFF\", if(copilot.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(copilot.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "copilot-five_hour-na", + "name": "Copilot five_hour unavailable", + "render": "!copilot.five_hour.available", + "parent": "copilot-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-five_hour-track", + "name": "Copilot five_hour track", + "parent": "copilot-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(copilot.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "copilot-five_hour-fill", + "name": "Copilot five_hour fill", + "render": "copilot.five_hour.available && copilot.five_hour.display > 0", + "parent": "copilot-five_hour", + "y": "21", + "width": "max(4, parent.width * min(copilot.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(copilot.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(copilot.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(copilot.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "copilot-five_hour-resets", + "name": "Copilot five_hour resets in", + "render": "copilot.five_hour.available && copilot.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(copilot.five_hour.reset.seconds < 60, 23, if(copilot.five_hour.reset.seconds < 3600, ((floor(copilot.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(copilot.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(copilot.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(copilot.five_hour.reset.seconds < 86400, ((floor(copilot.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(copilot.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(copilot.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(copilot.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(copilot.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(copilot.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(copilot.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(copilot.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(copilot.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(copilot.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(copilot.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(copilot.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "copilot-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-five_hour-countdown", + "name": "Copilot five_hour countdown", + "render": "copilot.five_hour.available && copilot.five_hour.reset.unix > 0", + "parent": "copilot-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{copilot.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-weekly", + "name": "Copilot weekly", + "parent": "copilot-cells", + "width": "(parent.width - ((1 + copilot.five_hour.available) - 1) * parent.gap) / max(1, 1 + copilot.five_hour.available)", + "height": "42" + }, + { + "id": "copilot-weekly-label", + "name": "Copilot weekly label", + "visibility": "if(parent.width - if(copilot.weekly.available, ((max(0, round(copilot.weekly.display))) >= 100) * if(floor((max(0, round(copilot.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.weekly.display))) >= 10) * if(floor((max(0, round(copilot.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.weekly.display))) % 10 == 1, 6, 8) + 12 + if(copilot.weekly.percentage >= 70 || copilot.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "copilot-weekly", + "width": "parent.width - if(copilot.weekly.available, ((max(0, round(copilot.weekly.display))) >= 100) * if(floor((max(0, round(copilot.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.weekly.display))) >= 10) * if(floor((max(0, round(copilot.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.weekly.display))) % 10 == 1, 6, 8) + 12 + if(copilot.weekly.percentage >= 70 || copilot.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "{if(copilot.weekly.label == \"30d\", \"Monthly\", if(copilot.weekly.label == \"API\", \"API\", \"Weekly\"))}", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(copilot.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(copilot.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "copilot-weekly-pill", + "name": "Copilot weekly level pill", + "render": "copilot.weekly.available && (copilot.weekly.percentage >= 70 || copilot.weekly.alarm)", + "parent": "copilot-weekly", + "x": "parent.width - (((max(0, round(copilot.weekly.display))) >= 100) * if(floor((max(0, round(copilot.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.weekly.display))) >= 10) * if(floor((max(0, round(copilot.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(copilot.weekly.display))) >= 100) * if(floor((max(0, round(copilot.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(copilot.weekly.display))) >= 10) * if(floor((max(0, round(copilot.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(copilot.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(copilot.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(copilot.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(copilot.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "copilot-weekly-value", + "name": "Copilot weekly value", + "render": "copilot.weekly.available", + "parent": "copilot-weekly", + "width": "parent.width - if(copilot.weekly.percentage >= 70 || copilot.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{copilot.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(copilot.weekly.alarm, \"#FFFFFF\", if(copilot.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(copilot.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "copilot-weekly-na", + "name": "Copilot weekly unavailable", + "render": "!copilot.weekly.available", + "parent": "copilot-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-weekly-track", + "name": "Copilot weekly track", + "parent": "copilot-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(copilot.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "copilot-weekly-fill", + "name": "Copilot weekly fill", + "render": "copilot.weekly.available && copilot.weekly.display > 0", + "parent": "copilot-weekly", + "y": "21", + "width": "max(4, parent.width * min(copilot.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(copilot.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(copilot.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(copilot.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "copilot-weekly-resets", + "name": "Copilot weekly resets in", + "render": "copilot.weekly.available && copilot.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(copilot.weekly.reset.seconds < 60, 23, if(copilot.weekly.reset.seconds < 3600, ((floor(copilot.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(copilot.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(copilot.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(copilot.weekly.reset.seconds < 86400, ((floor(copilot.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(copilot.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(copilot.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(copilot.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(copilot.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(copilot.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(copilot.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(copilot.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(copilot.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(copilot.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(copilot.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(copilot.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "copilot-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "copilot-weekly-countdown", + "name": "Copilot weekly countdown", + "render": "copilot.weekly.available && copilot.weekly.reset.unix > 0", + "parent": "copilot-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{copilot.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-divider", + "name": "Cursor divider", + "render": "providers.cursor.enabled && (providers.claude.enabled || providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled)", + "parent": "providers", + "y": "9", + "width": "1", + "height": "63", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-block", + "name": "Cursor block", + "render": "providers.cursor.enabled", + "parent": "providers", + "width": "max(cursor.available * parent.gap / 20 * ((1 + cursor.five_hour.available) * 128 - 16) + (1 - cursor.available) * 63, !(providers.grok.enabled || providers.opencode.enabled) * (253 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "cursor-halo", + "name": "Cursor halo", + "parent": "cursor-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#E0569B", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "cursor-dot", + "name": "Cursor dot", + "parent": "cursor-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#E0569B", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "cursor-name", + "name": "Cursor name", + "parent": "cursor-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "Cursor", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-na", + "name": "Cursor unavailable", + "render": "!cursor.available", + "parent": "cursor-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-cells", + "name": "Cursor limits", + "render": "cursor.available", + "parent": "cursor-block", + "y": "30", + "width": "parent.gap / 20 * ((1 + cursor.five_hour.available) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (1 + cursor.five_hour.available) * 128 - 16)" + }, + { + "id": "cursor-five_hour", + "name": "Cursor five_hour", + "render": "cursor.five_hour.available", + "parent": "cursor-cells", + "width": "(parent.width - ((1 + cursor.five_hour.available) - 1) * parent.gap) / max(1, 1 + cursor.five_hour.available)", + "height": "42" + }, + { + "id": "cursor-five_hour-label", + "name": "Cursor five_hour label", + "visibility": "if(parent.width - if(cursor.five_hour.available, ((max(0, round(cursor.five_hour.display))) >= 100) * if(floor((max(0, round(cursor.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.five_hour.display))) >= 10) * if(floor((max(0, round(cursor.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(cursor.five_hour.percentage >= 70 || cursor.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "cursor-five_hour", + "width": "parent.width - if(cursor.five_hour.available, ((max(0, round(cursor.five_hour.display))) >= 100) * if(floor((max(0, round(cursor.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.five_hour.display))) >= 10) * if(floor((max(0, round(cursor.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(cursor.five_hour.percentage >= 70 || cursor.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "Auto", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(cursor.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(cursor.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "cursor-five_hour-pill", + "name": "Cursor five_hour level pill", + "render": "cursor.five_hour.available && (cursor.five_hour.percentage >= 70 || cursor.five_hour.alarm)", + "parent": "cursor-five_hour", + "x": "parent.width - (((max(0, round(cursor.five_hour.display))) >= 100) * if(floor((max(0, round(cursor.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.five_hour.display))) >= 10) * if(floor((max(0, round(cursor.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(cursor.five_hour.display))) >= 100) * if(floor((max(0, round(cursor.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.five_hour.display))) >= 10) * if(floor((max(0, round(cursor.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(cursor.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(cursor.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(cursor.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "cursor-five_hour-value", + "name": "Cursor five_hour value", + "render": "cursor.five_hour.available", + "parent": "cursor-five_hour", + "width": "parent.width - if(cursor.five_hour.percentage >= 70 || cursor.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{cursor.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(cursor.five_hour.alarm, \"#FFFFFF\", if(cursor.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(cursor.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "cursor-five_hour-na", + "name": "Cursor five_hour unavailable", + "render": "!cursor.five_hour.available", + "parent": "cursor-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-five_hour-track", + "name": "Cursor five_hour track", + "parent": "cursor-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(cursor.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "cursor-five_hour-fill", + "name": "Cursor five_hour fill", + "render": "cursor.five_hour.available && cursor.five_hour.display > 0", + "parent": "cursor-five_hour", + "y": "21", + "width": "max(4, parent.width * min(cursor.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(cursor.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(cursor.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(cursor.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "cursor-five_hour-resets", + "name": "Cursor five_hour resets in", + "render": "cursor.five_hour.available && cursor.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(cursor.five_hour.reset.seconds < 60, 23, if(cursor.five_hour.reset.seconds < 3600, ((floor(cursor.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(cursor.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(cursor.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(cursor.five_hour.reset.seconds < 86400, ((floor(cursor.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(cursor.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(cursor.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(cursor.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(cursor.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(cursor.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(cursor.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(cursor.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(cursor.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(cursor.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(cursor.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(cursor.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "cursor-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-five_hour-countdown", + "name": "Cursor five_hour countdown", + "render": "cursor.five_hour.available && cursor.five_hour.reset.unix > 0", + "parent": "cursor-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{cursor.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-weekly", + "name": "Cursor weekly", + "parent": "cursor-cells", + "width": "(parent.width - ((1 + cursor.five_hour.available) - 1) * parent.gap) / max(1, 1 + cursor.five_hour.available)", + "height": "42" + }, + { + "id": "cursor-weekly-label", + "name": "Cursor weekly label", + "visibility": "if(parent.width - if(cursor.weekly.available, ((max(0, round(cursor.weekly.display))) >= 100) * if(floor((max(0, round(cursor.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.weekly.display))) >= 10) * if(floor((max(0, round(cursor.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.weekly.display))) % 10 == 1, 6, 8) + 12 + if(cursor.weekly.percentage >= 70 || cursor.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "cursor-weekly", + "width": "parent.width - if(cursor.weekly.available, ((max(0, round(cursor.weekly.display))) >= 100) * if(floor((max(0, round(cursor.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.weekly.display))) >= 10) * if(floor((max(0, round(cursor.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.weekly.display))) % 10 == 1, 6, 8) + 12 + if(cursor.weekly.percentage >= 70 || cursor.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "{if(cursor.weekly.label == \"30d\", \"Monthly\", if(cursor.weekly.label == \"API\", \"API\", \"Weekly\"))}", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(cursor.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(cursor.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "cursor-weekly-pill", + "name": "Cursor weekly level pill", + "render": "cursor.weekly.available && (cursor.weekly.percentage >= 70 || cursor.weekly.alarm)", + "parent": "cursor-weekly", + "x": "parent.width - (((max(0, round(cursor.weekly.display))) >= 100) * if(floor((max(0, round(cursor.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.weekly.display))) >= 10) * if(floor((max(0, round(cursor.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(cursor.weekly.display))) >= 100) * if(floor((max(0, round(cursor.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(cursor.weekly.display))) >= 10) * if(floor((max(0, round(cursor.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(cursor.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(cursor.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(cursor.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(cursor.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "cursor-weekly-value", + "name": "Cursor weekly value", + "render": "cursor.weekly.available", + "parent": "cursor-weekly", + "width": "parent.width - if(cursor.weekly.percentage >= 70 || cursor.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{cursor.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(cursor.weekly.alarm, \"#FFFFFF\", if(cursor.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(cursor.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "cursor-weekly-na", + "name": "Cursor weekly unavailable", + "render": "!cursor.weekly.available", + "parent": "cursor-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-weekly-track", + "name": "Cursor weekly track", + "parent": "cursor-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(cursor.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "cursor-weekly-fill", + "name": "Cursor weekly fill", + "render": "cursor.weekly.available && cursor.weekly.display > 0", + "parent": "cursor-weekly", + "y": "21", + "width": "max(4, parent.width * min(cursor.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(cursor.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(cursor.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(cursor.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "cursor-weekly-resets", + "name": "Cursor weekly resets in", + "render": "cursor.weekly.available && cursor.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(cursor.weekly.reset.seconds < 60, 23, if(cursor.weekly.reset.seconds < 3600, ((floor(cursor.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(cursor.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(cursor.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(cursor.weekly.reset.seconds < 86400, ((floor(cursor.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(cursor.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(cursor.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(cursor.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(cursor.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(cursor.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(cursor.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(cursor.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(cursor.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(cursor.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(cursor.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(cursor.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "cursor-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "cursor-weekly-countdown", + "name": "Cursor weekly countdown", + "render": "cursor.weekly.available && cursor.weekly.reset.unix > 0", + "parent": "cursor-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{cursor.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "grok-divider", + "name": "Grok divider", + "render": "providers.grok.enabled && (providers.claude.enabled || providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled)", + "parent": "providers", + "y": "9", + "width": "1", + "height": "63", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "1" + } + } + }, + { + "id": "grok-block", + "name": "Grok block", + "render": "providers.grok.enabled", + "parent": "providers", + "width": "max(grok.available * parent.gap / 20 * ((1 + grok.five_hour.available) * 128 - 16) + (1 - grok.available) * 52, !(providers.opencode.enabled) * (242 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "grok-halo", + "name": "Grok halo", + "parent": "grok-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#8A8A93", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "grok-dot", + "name": "Grok dot", + "parent": "grok-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#8A8A93", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "grok-name", + "name": "Grok name", + "parent": "grok-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "Grok", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "grok-na", + "name": "Grok unavailable", + "render": "!grok.available", + "parent": "grok-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "grok-cells", + "name": "Grok limits", + "render": "grok.available", + "parent": "grok-block", + "y": "30", + "width": "parent.gap / 20 * ((1 + grok.five_hour.available) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (1 + grok.five_hour.available) * 128 - 16)" + }, + { + "id": "grok-five_hour", + "name": "Grok five_hour", + "render": "grok.five_hour.available", + "parent": "grok-cells", + "width": "(parent.width - ((1 + grok.five_hour.available) - 1) * parent.gap) / max(1, 1 + grok.five_hour.available)", + "height": "42" + }, + { + "id": "grok-five_hour-label", + "name": "Grok five_hour label", + "visibility": "if(parent.width - if(grok.five_hour.available, ((max(0, round(grok.five_hour.display))) >= 100) * if(floor((max(0, round(grok.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.five_hour.display))) >= 10) * if(floor((max(0, round(grok.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(grok.five_hour.percentage >= 70 || grok.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "grok-five_hour", + "width": "parent.width - if(grok.five_hour.available, ((max(0, round(grok.five_hour.display))) >= 100) * if(floor((max(0, round(grok.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.five_hour.display))) >= 10) * if(floor((max(0, round(grok.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(grok.five_hour.percentage >= 70 || grok.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "5-hour", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(grok.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(grok.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "grok-five_hour-pill", + "name": "Grok five_hour level pill", + "render": "grok.five_hour.available && (grok.five_hour.percentage >= 70 || grok.five_hour.alarm)", + "parent": "grok-five_hour", + "x": "parent.width - (((max(0, round(grok.five_hour.display))) >= 100) * if(floor((max(0, round(grok.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.five_hour.display))) >= 10) * if(floor((max(0, round(grok.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(grok.five_hour.display))) >= 100) * if(floor((max(0, round(grok.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.five_hour.display))) >= 10) * if(floor((max(0, round(grok.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(grok.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(grok.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(grok.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "grok-five_hour-value", + "name": "Grok five_hour value", + "render": "grok.five_hour.available", + "parent": "grok-five_hour", + "width": "parent.width - if(grok.five_hour.percentage >= 70 || grok.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{grok.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(grok.five_hour.alarm, \"#FFFFFF\", if(grok.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(grok.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "grok-five_hour-na", + "name": "Grok five_hour unavailable", + "render": "!grok.five_hour.available", + "parent": "grok-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "grok-five_hour-track", + "name": "Grok five_hour track", + "parent": "grok-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(grok.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "grok-five_hour-fill", + "name": "Grok five_hour fill", + "render": "grok.five_hour.available && grok.five_hour.display > 0", + "parent": "grok-five_hour", + "y": "21", + "width": "max(4, parent.width * min(grok.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(grok.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(grok.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(grok.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "grok-five_hour-resets", + "name": "Grok five_hour resets in", + "render": "grok.five_hour.available && grok.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(grok.five_hour.reset.seconds < 60, 23, if(grok.five_hour.reset.seconds < 3600, ((floor(grok.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(grok.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(grok.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(grok.five_hour.reset.seconds < 86400, ((floor(grok.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(grok.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(grok.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(grok.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(grok.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(grok.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(grok.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(grok.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(grok.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(grok.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(grok.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(grok.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "grok-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "grok-five_hour-countdown", + "name": "Grok five_hour countdown", + "render": "grok.five_hour.available && grok.five_hour.reset.unix > 0", + "parent": "grok-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{grok.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "grok-weekly", + "name": "Grok weekly", + "parent": "grok-cells", + "width": "(parent.width - ((1 + grok.five_hour.available) - 1) * parent.gap) / max(1, 1 + grok.five_hour.available)", + "height": "42" + }, + { + "id": "grok-weekly-label", + "name": "Grok weekly label", + "visibility": "if(parent.width - if(grok.weekly.available, ((max(0, round(grok.weekly.display))) >= 100) * if(floor((max(0, round(grok.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.weekly.display))) >= 10) * if(floor((max(0, round(grok.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.weekly.display))) % 10 == 1, 6, 8) + 12 + if(grok.weekly.percentage >= 70 || grok.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "grok-weekly", + "width": "parent.width - if(grok.weekly.available, ((max(0, round(grok.weekly.display))) >= 100) * if(floor((max(0, round(grok.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.weekly.display))) >= 10) * if(floor((max(0, round(grok.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.weekly.display))) % 10 == 1, 6, 8) + 12 + if(grok.weekly.percentage >= 70 || grok.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "{if(grok.weekly.label == \"30d\", \"Monthly\", if(grok.weekly.label == \"API\", \"API\", \"Weekly\"))}", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(grok.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(grok.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "grok-weekly-pill", + "name": "Grok weekly level pill", + "render": "grok.weekly.available && (grok.weekly.percentage >= 70 || grok.weekly.alarm)", + "parent": "grok-weekly", + "x": "parent.width - (((max(0, round(grok.weekly.display))) >= 100) * if(floor((max(0, round(grok.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.weekly.display))) >= 10) * if(floor((max(0, round(grok.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(grok.weekly.display))) >= 100) * if(floor((max(0, round(grok.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(grok.weekly.display))) >= 10) * if(floor((max(0, round(grok.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(grok.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(grok.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(grok.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(grok.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "grok-weekly-value", + "name": "Grok weekly value", + "render": "grok.weekly.available", + "parent": "grok-weekly", + "width": "parent.width - if(grok.weekly.percentage >= 70 || grok.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{grok.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(grok.weekly.alarm, \"#FFFFFF\", if(grok.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(grok.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "grok-weekly-na", + "name": "Grok weekly unavailable", + "render": "!grok.weekly.available", + "parent": "grok-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "grok-weekly-track", + "name": "Grok weekly track", + "parent": "grok-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(grok.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "grok-weekly-fill", + "name": "Grok weekly fill", + "render": "grok.weekly.available && grok.weekly.display > 0", + "parent": "grok-weekly", + "y": "21", + "width": "max(4, parent.width * min(grok.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(grok.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(grok.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(grok.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "grok-weekly-resets", + "name": "Grok weekly resets in", + "render": "grok.weekly.available && grok.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(grok.weekly.reset.seconds < 60, 23, if(grok.weekly.reset.seconds < 3600, ((floor(grok.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(grok.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(grok.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(grok.weekly.reset.seconds < 86400, ((floor(grok.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(grok.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(grok.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(grok.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(grok.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(grok.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(grok.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(grok.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(grok.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(grok.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(grok.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(grok.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "grok-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "grok-weekly-countdown", + "name": "Grok weekly countdown", + "render": "grok.weekly.available && grok.weekly.reset.unix > 0", + "parent": "grok-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{grok.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-divider", + "name": "OpenCode divider", + "render": "providers.opencode.enabled && (providers.claude.enabled || providers.codex.enabled || providers.antigravity.enabled || providers.copilot.enabled || providers.cursor.enabled || providers.grok.enabled)", + "parent": "providers", + "y": "9", + "width": "1", + "height": "63", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-block", + "name": "OpenCode block", + "render": "providers.opencode.enabled", + "parent": "providers", + "width": "max(opencode.available * parent.gap / 20 * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16) + (1 - opencode.available) * 87, 1 * (277 + if(i18n.locale == \"en\", 0, if(i18n.locale == \"ja\", 9, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 13, if(i18n.locale == \"th\", 17, if(i18n.locale == \"de\", 30, if(i18n.locale == \"es\", 46, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 55, if(i18n.locale == \"nl\", 67, if(i18n.locale == \"pl\", 88, 38))))))))) + alarm.active * 102))", + "height": "80", + "gap": "parent.gap" + }, + { + "id": "opencode-halo", + "name": "OpenCode halo", + "parent": "opencode-block", + "y": "9.5", + "width": "13", + "height": "13", + "background": { + "type": "colour", + "colour": { + "color": "#6AA81A", + "opacity": "0.18" + } + }, + "corner_radius": "6.5" + }, + { + "id": "opencode-dot", + "name": "OpenCode dot", + "parent": "opencode-block", + "x": "3", + "y": "12.5", + "width": "7", + "height": "7", + "background": { + "type": "colour", + "colour": { + "color": "#6AA81A", + "opacity": "1" + } + }, + "corner_radius": "3.5" + }, + { + "id": "opencode-name", + "name": "OpenCode name", + "parent": "opencode-block", + "x": "17", + "y": "6", + "width": "parent.width - 17", + "height": "20", + "content": { + "type": "text", + "template": "OpenCode", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-na", + "name": "OpenCode unavailable", + "render": "!opencode.available", + "parent": "opencode-block", + "y": "30", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-cells", + "name": "OpenCode limits", + "render": "opencode.available", + "parent": "opencode-block", + "y": "30", + "width": "parent.gap / 20 * ((1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16)", + "height": "42", + "layout": "row", + "gap": "parent.width * 16 / max(1, (1 + opencode.five_hour.available + opencode.monthly.available) * 128 - 16)" + }, + { + "id": "opencode-five_hour", + "name": "OpenCode five_hour", + "render": "opencode.five_hour.available", + "parent": "opencode-cells", + "width": "(parent.width - ((1 + opencode.five_hour.available + opencode.monthly.available) - 1) * parent.gap) / max(1, 1 + opencode.five_hour.available + opencode.monthly.available)", + "height": "42" + }, + { + "id": "opencode-five_hour-label", + "name": "OpenCode five_hour label", + "visibility": "if(parent.width - if(opencode.five_hour.available, ((max(0, round(opencode.five_hour.display))) >= 100) * if(floor((max(0, round(opencode.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.five_hour.display))) >= 10) * if(floor((max(0, round(opencode.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(opencode.five_hour.percentage >= 70 || opencode.five_hour.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "opencode-five_hour", + "width": "parent.width - if(opencode.five_hour.available, ((max(0, round(opencode.five_hour.display))) >= 100) * if(floor((max(0, round(opencode.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.five_hour.display))) >= 10) * if(floor((max(0, round(opencode.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.five_hour.display))) % 10 == 1, 6, 8) + 12 + if(opencode.five_hour.percentage >= 70 || opencode.five_hour.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "5-hour", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(opencode.five_hour.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(opencode.five_hour.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "opencode-five_hour-pill", + "name": "OpenCode five_hour level pill", + "render": "opencode.five_hour.available && (opencode.five_hour.percentage >= 70 || opencode.five_hour.alarm)", + "parent": "opencode-five_hour", + "x": "parent.width - (((max(0, round(opencode.five_hour.display))) >= 100) * if(floor((max(0, round(opencode.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.five_hour.display))) >= 10) * if(floor((max(0, round(opencode.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(opencode.five_hour.display))) >= 100) * if(floor((max(0, round(opencode.five_hour.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.five_hour.display))) >= 10) * if(floor((max(0, round(opencode.five_hour.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.five_hour.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(opencode.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.five_hour.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(opencode.five_hour.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "opencode-five_hour-value", + "name": "OpenCode five_hour value", + "render": "opencode.five_hour.available", + "parent": "opencode-five_hour", + "width": "parent.width - if(opencode.five_hour.percentage >= 70 || opencode.five_hour.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{opencode.five_hour.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(opencode.five_hour.alarm, \"#FFFFFF\", if(opencode.five_hour.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(opencode.five_hour.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "opencode-five_hour-na", + "name": "OpenCode five_hour unavailable", + "render": "!opencode.five_hour.available", + "parent": "opencode-five_hour", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-five_hour-track", + "name": "OpenCode five_hour track", + "parent": "opencode-five_hour", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(opencode.five_hour.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "opencode-five_hour-fill", + "name": "OpenCode five_hour fill", + "render": "opencode.five_hour.available && opencode.five_hour.display > 0", + "parent": "opencode-five_hour", + "y": "21", + "width": "max(4, parent.width * min(opencode.five_hour.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(opencode.five_hour.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.five_hour.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.five_hour.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "opencode-five_hour-resets", + "name": "OpenCode five_hour resets in", + "render": "opencode.five_hour.available && opencode.five_hour.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(opencode.five_hour.reset.seconds < 60, 23, if(opencode.five_hour.reset.seconds < 3600, ((floor(opencode.five_hour.reset.seconds / 60)) >= 10) * if(floor((floor(opencode.five_hour.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(opencode.five_hour.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(opencode.five_hour.reset.seconds < 86400, ((floor(opencode.five_hour.reset.seconds / 3600)) >= 10) * if(floor((floor(opencode.five_hour.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(opencode.five_hour.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(opencode.five_hour.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(opencode.five_hour.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(opencode.five_hour.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(opencode.five_hour.reset.seconds / 86400)) >= 10) * if(floor((floor(opencode.five_hour.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(opencode.five_hour.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(opencode.five_hour.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(opencode.five_hour.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(opencode.five_hour.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "opencode-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-five_hour-countdown", + "name": "OpenCode five_hour countdown", + "render": "opencode.five_hour.available && opencode.five_hour.reset.unix > 0", + "parent": "opencode-five_hour", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{opencode.five_hour.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-weekly", + "name": "OpenCode weekly", + "parent": "opencode-cells", + "width": "(parent.width - ((1 + opencode.five_hour.available + opencode.monthly.available) - 1) * parent.gap) / max(1, 1 + opencode.five_hour.available + opencode.monthly.available)", + "height": "42" + }, + { + "id": "opencode-weekly-label", + "name": "OpenCode weekly label", + "visibility": "if(parent.width - if(opencode.weekly.available, ((max(0, round(opencode.weekly.display))) >= 100) * if(floor((max(0, round(opencode.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.weekly.display))) >= 10) * if(floor((max(0, round(opencode.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.weekly.display))) % 10 == 1, 6, 8) + 12 + if(opencode.weekly.percentage >= 70 || opencode.weekly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "opencode-weekly", + "width": "parent.width - if(opencode.weekly.available, ((max(0, round(opencode.weekly.display))) >= 100) * if(floor((max(0, round(opencode.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.weekly.display))) >= 10) * if(floor((max(0, round(opencode.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.weekly.display))) % 10 == 1, 6, 8) + 12 + if(opencode.weekly.percentage >= 70 || opencode.weekly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "{if(opencode.weekly.label == \"30d\", \"Monthly\", if(opencode.weekly.label == \"API\", \"API\", \"Weekly\"))}", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(opencode.weekly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(opencode.weekly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "opencode-weekly-pill", + "name": "OpenCode weekly level pill", + "render": "opencode.weekly.available && (opencode.weekly.percentage >= 70 || opencode.weekly.alarm)", + "parent": "opencode-weekly", + "x": "parent.width - (((max(0, round(opencode.weekly.display))) >= 100) * if(floor((max(0, round(opencode.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.weekly.display))) >= 10) * if(floor((max(0, round(opencode.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.weekly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(opencode.weekly.display))) >= 100) * if(floor((max(0, round(opencode.weekly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.weekly.display))) >= 10) * if(floor((max(0, round(opencode.weekly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.weekly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(opencode.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.weekly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(opencode.weekly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "opencode-weekly-value", + "name": "OpenCode weekly value", + "render": "opencode.weekly.available", + "parent": "opencode-weekly", + "width": "parent.width - if(opencode.weekly.percentage >= 70 || opencode.weekly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{opencode.weekly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(opencode.weekly.alarm, \"#FFFFFF\", if(opencode.weekly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(opencode.weekly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "opencode-weekly-na", + "name": "OpenCode weekly unavailable", + "render": "!opencode.weekly.available", + "parent": "opencode-weekly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-weekly-track", + "name": "OpenCode weekly track", + "parent": "opencode-weekly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(opencode.weekly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "opencode-weekly-fill", + "name": "OpenCode weekly fill", + "render": "opencode.weekly.available && opencode.weekly.display > 0", + "parent": "opencode-weekly", + "y": "21", + "width": "max(4, parent.width * min(opencode.weekly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(opencode.weekly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.weekly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.weekly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "opencode-weekly-resets", + "name": "OpenCode weekly resets in", + "render": "opencode.weekly.available && opencode.weekly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(opencode.weekly.reset.seconds < 60, 23, if(opencode.weekly.reset.seconds < 3600, ((floor(opencode.weekly.reset.seconds / 60)) >= 10) * if(floor((floor(opencode.weekly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(opencode.weekly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(opencode.weekly.reset.seconds < 86400, ((floor(opencode.weekly.reset.seconds / 3600)) >= 10) * if(floor((floor(opencode.weekly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(opencode.weekly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(opencode.weekly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(opencode.weekly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(opencode.weekly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(opencode.weekly.reset.seconds / 86400)) >= 10) * if(floor((floor(opencode.weekly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(opencode.weekly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(opencode.weekly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(opencode.weekly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(opencode.weekly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "opencode-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-weekly-countdown", + "name": "OpenCode weekly countdown", + "render": "opencode.weekly.available && opencode.weekly.reset.unix > 0", + "parent": "opencode-weekly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{opencode.weekly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-monthly", + "name": "OpenCode monthly", + "render": "opencode.monthly.available", + "parent": "opencode-cells", + "width": "(parent.width - ((1 + opencode.five_hour.available + opencode.monthly.available) - 1) * parent.gap) / max(1, 1 + opencode.five_hour.available + opencode.monthly.available)", + "height": "42" + }, + { + "id": "opencode-monthly-label", + "name": "OpenCode monthly label", + "visibility": "if(parent.width - if(opencode.monthly.available, ((max(0, round(opencode.monthly.display))) >= 100) * if(floor((max(0, round(opencode.monthly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.monthly.display))) >= 10) * if(floor((max(0, round(opencode.monthly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.monthly.display))) % 10 == 1, 6, 8) + 12 + if(opencode.monthly.percentage >= 70 || opencode.monthly.alarm, 14, 0), 22) - 6 >= 10, 100, 0)", + "parent": "opencode-monthly", + "width": "parent.width - if(opencode.monthly.available, ((max(0, round(opencode.monthly.display))) >= 100) * if(floor((max(0, round(opencode.monthly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.monthly.display))) >= 10) * if(floor((max(0, round(opencode.monthly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.monthly.display))) % 10 == 1, 6, 8) + 12 + if(opencode.monthly.percentage >= 70 || opencode.monthly.alarm, 14, 0), 22) - 6", + "height": "18", + "content": { + "type": "text", + "template": "Monthly", + "font_size": "12", + "contrast": "1.2", + "color": { + "color": "if(opencode.monthly.alarm, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(opencode.monthly.available, if(system.dark, \"#9B9BA2\", \"#6B6B6B\"), if(system.dark, \"#6B6B73\", \"#A3A3A3\")))", + "opacity": "1" + } + } + }, + { + "id": "opencode-monthly-pill", + "name": "OpenCode monthly level pill", + "render": "opencode.monthly.available && (opencode.monthly.percentage >= 70 || opencode.monthly.alarm)", + "parent": "opencode-monthly", + "x": "parent.width - (((max(0, round(opencode.monthly.display))) >= 100) * if(floor((max(0, round(opencode.monthly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.monthly.display))) >= 10) * if(floor((max(0, round(opencode.monthly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.monthly.display))) % 10 == 1, 6, 8) + 12 + 14)", + "width": "((max(0, round(opencode.monthly.display))) >= 100) * if(floor((max(0, round(opencode.monthly.display))) / 100) == 1, 6, 8) + ((max(0, round(opencode.monthly.display))) >= 10) * if(floor((max(0, round(opencode.monthly.display))) / 10) % 10 == 1, 6, 8) + if((max(0, round(opencode.monthly.display))) % 10 == 1, 6, 8) + 12 + 14", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(opencode.monthly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.monthly.percentage >= 90, if(system.dark, \"#F26B6B1F\", \"#FEF0EF\"), if(opencode.monthly.percentage >= 70, if(system.dark, \"#FFB0201F\", \"#FFF4E0\"), if(system.dark, \"#242428\", \"#F0F0F0\"))))", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "opencode-monthly-value", + "name": "OpenCode monthly value", + "render": "opencode.monthly.available", + "parent": "opencode-monthly", + "width": "parent.width - if(opencode.monthly.percentage >= 70 || opencode.monthly.alarm, 7, 0)", + "height": "18", + "content": { + "type": "text", + "template": "{opencode.monthly.display:0}%", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(opencode.monthly.alarm, \"#FFFFFF\", if(opencode.monthly.percentage >= 90, if(system.dark, \"#FF8F8F\", \"#B42318\"), if(opencode.monthly.percentage >= 70, if(system.dark, \"#FFC35C\", \"#8A5600\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + } + }, + { + "id": "opencode-monthly-na", + "name": "OpenCode monthly unavailable", + "render": "!opencode.monthly.available", + "parent": "opencode-monthly", + "width": "parent.width", + "height": "18", + "content": { + "type": "text", + "template": "n/a", + "font_size": "13", + "contrast": "1.2", + "weight": "semibold", + "align": "right", + "color": { + "color": "if(system.dark, \"#6B6B73\", \"#A3A3A3\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-monthly-track", + "name": "OpenCode monthly track", + "parent": "opencode-monthly", + "y": "21", + "width": "parent.width", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2A2A2F\", \"#EBEBEB\")", + "opacity": "if(opencode.monthly.available, 1, 0.5)" + } + }, + "corner_radius": "2" + }, + { + "id": "opencode-monthly-fill", + "name": "OpenCode monthly fill", + "render": "opencode.monthly.available && opencode.monthly.display > 0", + "parent": "opencode-monthly", + "y": "21", + "width": "max(4, parent.width * min(opencode.monthly.display, 100) / 100)", + "height": "4", + "background": { + "type": "colour", + "colour": { + "color": "if(opencode.monthly.alarm, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.monthly.percentage >= 90, if(system.dark, \"#F26B6B\", \"#EF4444\"), if(opencode.monthly.percentage >= 70, if(system.dark, \"#FFB020\", \"#FFA100\"), if(system.dark, \"#EDEDEF\", \"#171717\"))))", + "opacity": "1" + } + }, + "corner_radius": "2" + }, + { + "id": "opencode-monthly-resets", + "name": "OpenCode monthly resets in", + "render": "opencode.monthly.available && opencode.monthly.reset.unix > 0", + "visibility": "if(parent.width >= ((45) * 1.045 + 1) + 6 + ((if(opencode.monthly.reset.seconds < 60, 23, if(opencode.monthly.reset.seconds < 3600, ((floor(opencode.monthly.reset.seconds / 60)) >= 10) * if(floor((floor(opencode.monthly.reset.seconds / 60)) / 10) == 1, 5, 6) + if((floor(opencode.monthly.reset.seconds / 60)) % 10 == 1, 5, 6) + 10, if(opencode.monthly.reset.seconds < 86400, ((floor(opencode.monthly.reset.seconds / 3600)) >= 10) * if(floor((floor(opencode.monthly.reset.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(opencode.monthly.reset.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(opencode.monthly.reset.seconds % 3600 / 60)) >= 10) * if(floor((floor(opencode.monthly.reset.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(opencode.monthly.reset.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(opencode.monthly.reset.seconds / 86400)) >= 10) * if(floor((floor(opencode.monthly.reset.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(opencode.monthly.reset.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(opencode.monthly.reset.seconds % 86400 / 3600)) >= 10) * if(floor((floor(opencode.monthly.reset.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(opencode.monthly.reset.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\" || i18n.locale == \"tr\", 8, if(i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"ja\" || i18n.locale == \"ko\", 24, 0)))) * 1.045 + 1), 100, 0)", + "parent": "opencode-monthly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "Resets in", + "font_size": "11", + "contrast": "1.2", + "color": { + "color": "if(system.dark, \"#9B9BA2\", \"#6B6B6B\")", + "opacity": "1" + } + } + }, + { + "id": "opencode-monthly-countdown", + "name": "OpenCode monthly countdown", + "render": "opencode.monthly.available && opencode.monthly.reset.unix > 0", + "parent": "opencode-monthly", + "y": "28", + "width": "parent.width", + "height": "14", + "content": { + "type": "text", + "template": "{opencode.monthly.reset.seconds:countdown}", + "font_size": "12", + "contrast": "1.2", + "align": "right", + "color": { + "color": "if(system.dark, \"#EDEDEF\", \"#171717\")", + "opacity": "1" + } + } + }, + { + "id": "status-waiting-pill", + "name": "Waiting pill", + "render": "data.loading", + "x": "canvas.width - 20 - ((119 + if(i18n.locale == \"fr\", 4, if(i18n.locale == \"es\", 8, if(i18n.locale == \"de\", 16, if(i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"tr\", 24, if(i18n.locale == \"pl\", 28, if(i18n.locale == \"nl\", 44, 0)))))))) * 1.045 + 1) - 24", + "y": "7", + "width": "((119 + if(i18n.locale == \"fr\", 4, if(i18n.locale == \"es\", 8, if(i18n.locale == \"de\", 16, if(i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"tr\", 24, if(i18n.locale == \"pl\", 28, if(i18n.locale == \"nl\", 44, 0)))))))) * 1.045 + 1) + 24", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#242428\", \"#F0F0F0\")", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "status-waiting-dot", + "name": "Waiting dot", + "parent": "status-waiting-pill", + "x": "8", + "y": "6.0", + "width": "6", + "height": "6", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#55555C\", \"#C7C7C7\")", + "opacity": "1" + } + }, + "corner_radius": "3" + }, + { + "id": "status-waiting", + "name": "Waiting status", + "parent": "status-waiting-pill", + "x": "18", + "width": "parent.width - 24", + "height": "18", + "content": { + "type": "text", + "template": "{i18n.status_waiting}", + "font_size": "12", + "contrast": "1.2", + "align": "center", + "color": { + "color": "if(system.dark, \"#B4B4BA\", \"#5C5C5C\")", + "opacity": "1" + } + } + }, + { + "id": "status-live-pill", + "name": "Live pill", + "render": "!data.loading && !(data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)) && data.updated.available", + "x": "canvas.width - 20 - ((100 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"en\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 0, if(i18n.locale == \"ja\" || i18n.locale == \"th\", 4, if(i18n.locale == \"de\", 12, if(i18n.locale == \"pt-BR\", 24, if(i18n.locale == \"es\", 40, if(i18n.locale == \"ru\", 48, if(i18n.locale == \"pl\", 68, 36)))))))) * 1.045 + 1) - 24", + "y": "7", + "width": "((100 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"en\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 0, if(i18n.locale == \"ja\" || i18n.locale == \"th\", 4, if(i18n.locale == \"de\", 12, if(i18n.locale == \"pt-BR\", 24, if(i18n.locale == \"es\", 40, if(i18n.locale == \"ru\", 48, if(i18n.locale == \"pl\", 68, 36)))))))) * 1.045 + 1) + 24", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2FD27F1F\", \"#E4FCEF\")", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "status-live-dot", + "name": "Live dot", + "parent": "status-live-pill", + "x": "8", + "y": "6.0", + "width": "6", + "height": "6", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2FD27F\", \"#18C669\")", + "opacity": "1" + } + }, + "corner_radius": "3" + }, + { + "id": "status-live", + "name": "Live status", + "parent": "status-live-pill", + "x": "18", + "width": "parent.width - 24", + "height": "18", + "content": { + "type": "text", + "template": "{i18n.status_live_updated}", + "font_size": "12", + "contrast": "1.2", + "align": "center", + "color": { + "color": "if(system.dark, \"#4ADE94\", \"#0F7A4A\")", + "opacity": "1" + } + } + }, + { + "id": "status-live-untimed-pill", + "name": "Live (no time) pill", + "render": "!data.loading && !(data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)) && !data.updated.available", + "x": "canvas.width - 20 - ((21 + if(i18n.locale == \"zh-TW\", 4, if(i18n.locale == \"ja\" || i18n.locale == \"tr\" || i18n.locale == \"zh-CN\", 8, if(i18n.locale == \"ko\", 16, if(i18n.locale == \"es\" || i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"pl\" || i18n.locale == \"ru\", 24, if(i18n.locale == \"fr\", 28, 0))))))) * 1.045 + 1) - 24", + "y": "7", + "width": "((21 + if(i18n.locale == \"zh-TW\", 4, if(i18n.locale == \"ja\" || i18n.locale == \"tr\" || i18n.locale == \"zh-CN\", 8, if(i18n.locale == \"ko\", 16, if(i18n.locale == \"es\" || i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"pl\" || i18n.locale == \"ru\", 24, if(i18n.locale == \"fr\", 28, 0))))))) * 1.045 + 1) + 24", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2FD27F1F\", \"#E4FCEF\")", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "status-live-untimed-dot", + "name": "Live (no time) dot", + "parent": "status-live-untimed-pill", + "x": "8", + "y": "6.0", + "width": "6", + "height": "6", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#2FD27F\", \"#18C669\")", + "opacity": "1" + } + }, + "corner_radius": "3" + }, + { + "id": "status-live-untimed", + "name": "Live (no time) status", + "parent": "status-live-untimed-pill", + "x": "18", + "width": "parent.width - 24", + "height": "18", + "content": { + "type": "text", + "template": "{i18n.status_live}", + "font_size": "12", + "contrast": "1.2", + "align": "center", + "color": { + "color": "if(system.dark, \"#4ADE94\", \"#0F7A4A\")", + "opacity": "1" + } + } + }, + { + "id": "status-stale-pill", + "name": "Stale pill", + "render": "!data.loading && (data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)) && data.updated.available", + "x": "canvas.width - 20 - ((104 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\", 16, if(i18n.locale == \"de\" || i18n.locale == \"tr\", 28, if(i18n.locale == \"ko\", 32, if(i18n.locale == \"fr\", 36, if(i18n.locale == \"es\", 44, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 52, if(i18n.locale == \"nl\", 64, if(i18n.locale == \"pl\", 84, 0))))))))) * 1.045 + 1) - 24", + "y": "7", + "width": "((104 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\", 16, if(i18n.locale == \"de\" || i18n.locale == \"tr\", 28, if(i18n.locale == \"ko\", 32, if(i18n.locale == \"fr\", 36, if(i18n.locale == \"es\", 44, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 52, if(i18n.locale == \"nl\", 64, if(i18n.locale == \"pl\", 84, 0))))))))) * 1.045 + 1) + 24", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#FFB0201F\", \"#FFF4E0\")", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "status-stale-dot", + "name": "Stale dot", + "parent": "status-stale-pill", + "x": "8", + "y": "6.0", + "width": "6", + "height": "6", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#FFB020\", \"#FFA100\")", + "opacity": "1" + } + }, + "corner_radius": "3" + }, + { + "id": "status-stale", + "name": "Stale status", + "parent": "status-stale-pill", + "x": "18", + "width": "parent.width - 24", + "height": "18", + "content": { + "type": "text", + "template": "{i18n.status_stale_updated}", + "font_size": "12", + "contrast": "1.2", + "align": "center", + "color": { + "color": "if(system.dark, \"#FFC35C\", \"#8A5600\")", + "opacity": "1" + } + } + }, + { + "id": "status-stale-untimed-pill", + "name": "Stale (no time) pill", + "render": "!data.loading && (data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)) && !data.updated.available", + "x": "canvas.width - 20 - ((25 + if(i18n.locale == \"th\", 8, if(i18n.locale == \"ko\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"de\", 16, if(i18n.locale == \"es\" || i18n.locale == \"fr\", 24, if(i18n.locale == \"ru\", 28, if(i18n.locale == \"nl\", 32, if(i18n.locale == \"pl\", 40, if(i18n.locale == \"pt-BR\", 48, 0))))))))) * 1.045 + 1) - 24", + "y": "7", + "width": "((25 + if(i18n.locale == \"th\", 8, if(i18n.locale == \"ko\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"de\", 16, if(i18n.locale == \"es\" || i18n.locale == \"fr\", 24, if(i18n.locale == \"ru\", 28, if(i18n.locale == \"nl\", 32, if(i18n.locale == \"pl\", 40, if(i18n.locale == \"pt-BR\", 48, 0))))))))) * 1.045 + 1) + 24", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#FFB0201F\", \"#FFF4E0\")", + "opacity": "1" + } + }, + "corner_radius": "8" + }, + { + "id": "status-stale-untimed-dot", + "name": "Stale (no time) dot", + "parent": "status-stale-untimed-pill", + "x": "8", + "y": "6.0", + "width": "6", + "height": "6", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#FFB020\", \"#FFA100\")", + "opacity": "1" + } + }, + "corner_radius": "3" + }, + { + "id": "status-stale-untimed", + "name": "Stale (no time) status", + "parent": "status-stale-untimed-pill", + "x": "18", + "width": "parent.width - 24", + "height": "18", + "content": { + "type": "text", + "template": "{i18n.status_stale}", + "font_size": "12", + "contrast": "1.2", + "align": "center", + "color": { + "color": "if(system.dark, \"#FFC35C\", \"#8A5600\")", + "opacity": "1" + } + } + }, + { + "id": "snooze-button", + "name": "Snooze button", + "render": "alarm.active", + "x": "canvas.width - 20 - (if(data.loading, ((119 + if(i18n.locale == \"fr\", 4, if(i18n.locale == \"es\", 8, if(i18n.locale == \"de\", 16, if(i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"tr\", 24, if(i18n.locale == \"pl\", 28, if(i18n.locale == \"nl\", 44, 0)))))))) * 1.045 + 1), if((data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)), if(data.updated.available, ((104 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\", 16, if(i18n.locale == \"de\" || i18n.locale == \"tr\", 28, if(i18n.locale == \"ko\", 32, if(i18n.locale == \"fr\", 36, if(i18n.locale == \"es\", 44, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 52, if(i18n.locale == \"nl\", 64, if(i18n.locale == \"pl\", 84, 0))))))))) * 1.045 + 1), ((25 + if(i18n.locale == \"th\", 8, if(i18n.locale == \"ko\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"de\", 16, if(i18n.locale == \"es\" || i18n.locale == \"fr\", 24, if(i18n.locale == \"ru\", 28, if(i18n.locale == \"nl\", 32, if(i18n.locale == \"pl\", 40, if(i18n.locale == \"pt-BR\", 48, 0))))))))) * 1.045 + 1)), if(data.updated.available, ((100 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"en\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 0, if(i18n.locale == \"ja\" || i18n.locale == \"th\", 4, if(i18n.locale == \"de\", 12, if(i18n.locale == \"pt-BR\", 24, if(i18n.locale == \"es\", 40, if(i18n.locale == \"ru\", 48, if(i18n.locale == \"pl\", 68, 36)))))))) * 1.045 + 1), ((21 + if(i18n.locale == \"zh-TW\", 4, if(i18n.locale == \"ja\" || i18n.locale == \"tr\" || i18n.locale == \"zh-CN\", 8, if(i18n.locale == \"ko\", 16, if(i18n.locale == \"es\" || i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"pl\" || i18n.locale == \"ru\", 24, if(i18n.locale == \"fr\", 28, 0))))))) * 1.045 + 1))))) - 24 - 6 - 96", + "y": "7", + "width": "96", + "height": "18", + "background": { + "type": "colour", + "colour": { + "color": "if(system.dark, \"#F26B6B1F\", \"#FEF0EF\")", + "opacity": "1" + } + }, + "border": { + "color": { + "color": "if(system.dark, \"#F26B6B\", \"#EF4444\")", + "opacity": "0.45" + }, + "width": "1" + }, + "mouse_events": { + "click": "snooze_alarm()" + }, + "corner_radius": "8" + }, + { + "id": "snooze-button-icon", + "name": "Snooze button icon", + "render": "alarm.active", + "x": "canvas.width - 20 - (if(data.loading, ((119 + if(i18n.locale == \"fr\", 4, if(i18n.locale == \"es\", 8, if(i18n.locale == \"de\", 16, if(i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"tr\", 24, if(i18n.locale == \"pl\", 28, if(i18n.locale == \"nl\", 44, 0)))))))) * 1.045 + 1), if((data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)), if(data.updated.available, ((104 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\", 16, if(i18n.locale == \"de\" || i18n.locale == \"tr\", 28, if(i18n.locale == \"ko\", 32, if(i18n.locale == \"fr\", 36, if(i18n.locale == \"es\", 44, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 52, if(i18n.locale == \"nl\", 64, if(i18n.locale == \"pl\", 84, 0))))))))) * 1.045 + 1), ((25 + if(i18n.locale == \"th\", 8, if(i18n.locale == \"ko\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"de\", 16, if(i18n.locale == \"es\" || i18n.locale == \"fr\", 24, if(i18n.locale == \"ru\", 28, if(i18n.locale == \"nl\", 32, if(i18n.locale == \"pl\", 40, if(i18n.locale == \"pt-BR\", 48, 0))))))))) * 1.045 + 1)), if(data.updated.available, ((100 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"en\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 0, if(i18n.locale == \"ja\" || i18n.locale == \"th\", 4, if(i18n.locale == \"de\", 12, if(i18n.locale == \"pt-BR\", 24, if(i18n.locale == \"es\", 40, if(i18n.locale == \"ru\", 48, if(i18n.locale == \"pl\", 68, 36)))))))) * 1.045 + 1), ((21 + if(i18n.locale == \"zh-TW\", 4, if(i18n.locale == \"ja\" || i18n.locale == \"tr\" || i18n.locale == \"zh-CN\", 8, if(i18n.locale == \"ko\", 16, if(i18n.locale == \"es\" || i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"pl\" || i18n.locale == \"ru\", 24, if(i18n.locale == \"fr\", 28, 0))))))) * 1.045 + 1))))) - 24 - 6 - 96 + 7", + "y": "7", + "width": "12", + "height": "18", + "content": { + "type": "text", + "template": "", + "font_size": "11", + "contrast": "1.2", + "font_family": "Segoe MDL2 Assets", + "align": "center", + "color": { + "color": "if(system.dark, \"#FF8F8F\", \"#B42318\")", + "opacity": "1" + } + } + }, + { + "id": "snooze-button-text", + "name": "Snooze button text", + "render": "alarm.active", + "x": "canvas.width - 20 - (if(data.loading, ((119 + if(i18n.locale == \"fr\", 4, if(i18n.locale == \"es\", 8, if(i18n.locale == \"de\", 16, if(i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"tr\", 24, if(i18n.locale == \"pl\", 28, if(i18n.locale == \"nl\", 44, 0)))))))) * 1.045 + 1), if((data.has_error || (providers.claude.enabled && claude.stale) || (providers.codex.enabled && codex.stale) || (providers.antigravity.enabled && antigravity.stale) || (providers.copilot.enabled && copilot.stale) || (providers.cursor.enabled && cursor.stale) || (providers.grok.enabled && grok.stale) || (providers.opencode.enabled && opencode.stale)), if(data.updated.available, ((104 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"th\", 16, if(i18n.locale == \"de\" || i18n.locale == \"tr\", 28, if(i18n.locale == \"ko\", 32, if(i18n.locale == \"fr\", 36, if(i18n.locale == \"es\", 44, if(i18n.locale == \"pt-BR\" || i18n.locale == \"ru\", 52, if(i18n.locale == \"nl\", 64, if(i18n.locale == \"pl\", 84, 0))))))))) * 1.045 + 1), ((25 + if(i18n.locale == \"th\", 8, if(i18n.locale == \"ko\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 12, if(i18n.locale == \"de\", 16, if(i18n.locale == \"es\" || i18n.locale == \"fr\", 24, if(i18n.locale == \"ru\", 28, if(i18n.locale == \"nl\", 32, if(i18n.locale == \"pl\", 40, if(i18n.locale == \"pt-BR\", 48, 0))))))))) * 1.045 + 1)), if(data.updated.available, ((100 + if(data.updated.seconds < 60, 23, if(data.updated.seconds < 3600, ((floor(data.updated.seconds / 60)) >= 10) * if(floor((floor(data.updated.seconds / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 60)) % 10 == 1, 5, 6) + 10, if(data.updated.seconds < 86400, ((floor(data.updated.seconds / 3600)) >= 10) * if(floor((floor(data.updated.seconds / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 3600)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 3600 / 60)) >= 10) * if(floor((floor(data.updated.seconds % 3600 / 60)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 3600 / 60)) % 10 == 1, 5, 6) + 20, ((floor(data.updated.seconds / 86400)) >= 10) * if(floor((floor(data.updated.seconds / 86400)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds / 86400)) % 10 == 1, 5, 6) + ((floor(data.updated.seconds % 86400 / 3600)) >= 10) * if(floor((floor(data.updated.seconds % 86400 / 3600)) / 10) == 1, 5, 6) + if((floor(data.updated.seconds % 86400 / 3600)) % 10 == 1, 5, 6) + 17))) + if(i18n.locale == \"en\" || i18n.locale == \"zh-CN\" || i18n.locale == \"zh-TW\", 0, if(i18n.locale == \"ja\" || i18n.locale == \"th\", 4, if(i18n.locale == \"de\", 12, if(i18n.locale == \"pt-BR\", 24, if(i18n.locale == \"es\", 40, if(i18n.locale == \"ru\", 48, if(i18n.locale == \"pl\", 68, 36)))))))) * 1.045 + 1), ((21 + if(i18n.locale == \"zh-TW\", 4, if(i18n.locale == \"ja\" || i18n.locale == \"tr\" || i18n.locale == \"zh-CN\", 8, if(i18n.locale == \"ko\", 16, if(i18n.locale == \"es\" || i18n.locale == \"pt-BR\", 20, if(i18n.locale == \"pl\" || i18n.locale == \"ru\", 24, if(i18n.locale == \"fr\", 28, 0))))))) * 1.045 + 1))))) - 24 - 6 - 96 + 23", + "y": "7", + "width": "66", + "height": "18", + "content": { + "type": "text", + "template": "Snooze 1h", + "font_size": "12", + "contrast": "1.2", + "weight": "semibold", + "align": "center", + "color": { + "color": "if(system.dark, \"#FF8F8F\", \"#B42318\")", + "opacity": "1" + } + } + } + ] + } + ] +} diff --git a/src/ui/components/color_picker.rs b/src/ui/components/color_picker.rs index 43143dc7..f58af412 100644 --- a/src/ui/components/color_picker.rs +++ b/src/ui/components/color_picker.rs @@ -56,16 +56,19 @@ pub(crate) fn color_button(ui: &mut egui::Ui, color: &mut [u8; 4]) -> egui::Resp response } +/// `shown` is the evaluated colour when `value` is an expression; the text is +/// only rewritten when the user picks a new colour. pub(crate) fn color_string_field( ui: &mut egui::Ui, value: &mut String, width: f32, + shown: Option<[u8; 4]>, ) -> egui::Response { ui.allocate_ui_with_layout( egui::vec2(width, CONTROL_HEIGHT), egui::Layout::left_to_right(egui::Align::Center), |ui| { - let mut color = parse_color(value).unwrap_or([255, 0, 255, 255]); + let mut color = parse_color(value).or(shown).unwrap_or([255, 0, 255, 255]); let button_width = ui.spacing().interact_size.x; let text_width = (width - button_width - ui.spacing().item_spacing.x).max(1.0); let button = color_button(ui, &mut color); @@ -323,7 +326,7 @@ mod tests { let mut size = egui::Vec2::ZERO; let mut output = context.run_ui(egui::RawInput::default(), |ui| { let mut value = "#FFFFFFFF".to_owned(); - size = color_string_field(ui, &mut value, 240.0).rect.size(); + size = color_string_field(ui, &mut value, 240.0, None).rect.size(); }); output.textures_delta.clear(); diff --git a/src/window.rs b/src/window.rs index 4abd7ec0..73d8def1 100644 --- a/src/window.rs +++ b/src/window.rs @@ -29,11 +29,11 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, TIMER_CLOCK, TIMER_COUNTDOWN, TIMER_MOUSE_CLICK, TIMER_POLL, TIMER_RESET_POLL, - TIMER_TRAY_HOVER, TIMER_TRAY_REPOSITION, TIMER_UPDATE_CHECK, TIMER_WINDOW_STATE, - WM_APP_DISABLE_DIAGNOSTICS, WM_APP_ENABLE_DIAGNOSTICS, WM_APP_OPEN_DASHBOARD, WM_APP_QUIT, - WM_APP_REFRESH_NOW, WM_APP_SETTINGS_UPDATED, WM_APP_TASKBAR_COLLISION, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, + self, TIMER_ALARM_SNOOZE, TIMER_AUTO_HIDE, TIMER_CLOCK, TIMER_COUNTDOWN, TIMER_MOUSE_CLICK, + TIMER_POLL, TIMER_RESET_POLL, TIMER_TRAY_HOVER, TIMER_TRAY_REPOSITION, TIMER_UPDATE_CHECK, + TIMER_WINDOW_STATE, WM_APP_DISABLE_DIAGNOSTICS, WM_APP_ENABLE_DIAGNOSTICS, + WM_APP_OPEN_DASHBOARD, WM_APP_QUIT, WM_APP_REFRESH_NOW, WM_APP_SETTINGS_UPDATED, + WM_APP_TASKBAR_COLLISION, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; use crate::providers::{ProviderId, ProviderSet}; @@ -106,6 +106,10 @@ struct AppState { auth_watch_snapshot: poller::CredentialWatchSnapshot, last_poll_ok: bool, last_poll_failure: Option, + /// When usage last arrived from a successful poll. + last_update_unix: Option, + /// The last poll that delivered a non-stale reading; drives `data.updated.*`. + last_fresh_unix: Option, update_status: UpdateStatus, last_update_check_unix: Option, @@ -127,6 +131,12 @@ struct AppState { is_snapped: bool, placement_override: Option, floating_card_opacity: Option, + /// Settings overrides for edge-docked surfaces; `None` keeps the theme's. + hide_until_hover: Option, + edge_display: Option, + /// The "Alarm at 5% remaining" setting. + low_usage_alarm: bool, + alarm: low_usage_alarm::LowUsageAlarm, window_state_timer_active: bool, custom_theme_enabled: bool, @@ -143,6 +153,32 @@ struct AppState { suppress_next_left_up: bool, } +impl AppState { + /// Stamp the fresh time only when the INCOMING poll result (before any + /// merge with older readings) brought a non-stale reading. + fn note_fresh(&mut self, incoming: &AppUsageData) { + if incoming.has_fresh_reading() { + self.last_fresh_unix = Some(now_unix_secs()); + } + } + + /// Fold one incremental poll update into the state; returns the merged data. + fn apply_progress( + &mut self, + update: AppUsageData, + accounts: &crate::accounts::AccountSettings, + ) -> AppUsageData { + self.note_fresh(&update); + let data = + poller::merge_poll_progress(update, &self.data.clone().unwrap_or_default(), accounts); + self.data = Some(data.clone()); + self.last_poll_ok = true; + self.last_poll_failure = None; + self.last_update_unix = Some(now_unix_secs()); + data + } +} + #[derive(Clone, Debug)] struct PendingMouseClick { surface_index: usize, @@ -595,12 +631,69 @@ fn theme_runtime_from_state(state: &AppState) -> ThemeRuntime { SurfaceNest::Taskbar }; let opacity = state.floating_card_opacity.unwrap_or(85); + let now = Instant::now(); ThemeRuntime::from_providers(state.providers) .with_poll_state(poll_ok, has_error) .with_language(state.language) .with_countdown(state.usage_countdown) .with_nest(nest) .with_floating_card_opacity(opacity) + .with_updated_unix(state.last_fresh_unix) + .with_alarm(theme_engine::ThemeAlarm { + enabled: state.low_usage_alarm, + active: state.alarm.active(now), + snoozed: state.alarm.snoozed(now), + }) +} + +/// Bring the alarm up to date with the usage about to be drawn, sounding it +/// when a quota has just become almost spent. Returns whether it sounded. +/// Every quota of every enabled provider and account counts, whatever the +/// active theme shows. No data yet changes nothing. +fn update_low_usage_alarm(state: &mut AppState) -> bool { + update_low_usage_alarm_at(state, std::time::SystemTime::now(), Instant::now()) +} + +fn update_low_usage_alarm_at( + state: &mut AppState, + now: std::time::SystemTime, + mono: Instant, +) -> bool { + if !state.low_usage_alarm { + state.alarm.clear(); + return false; + } + let Some(data) = state.data.as_ref() else { + return false; + }; + let scan = theme_engine::alarm_scan(data, state.providers); + state.alarm.update(&scan, now, mono) +} + +fn snooze_low_usage_alarm(hwnd: HWND) { + if let Some(state) = lock_state().as_mut() { + state.alarm.snooze(Instant::now()); + } + schedule_alarm_snooze_timer(hwnd); + render_layered(); +} + +/// Re-render when a snooze runs out so the alarm can hold surfaces open again. +fn schedule_alarm_snooze_timer(hwnd: HWND) { + let remaining = lock_state() + .as_ref() + .and_then(|state| state.alarm.snooze_remaining(Instant::now())); + unsafe { + match remaining { + Some(remaining) => { + let ms = remaining.as_millis().clamp(1, u32::MAX as u128) as u32; + SetTimer(Some(hwnd), TIMER_ALARM_SNOOZE, ms, None); + } + None => { + let _ = KillTimer(Some(hwnd), TIMER_ALARM_SNOOZE); + } + } + } } /// A transient outage can keep presenting the last real reading while its @@ -635,6 +728,17 @@ fn theme_with_placement(state: &AppState, auto_ejected: bool) -> Option String { } fn sync_tray_icon(hwnd: HWND) { + // Tests never touch Explorer. + if cfg!(test) { + return; + } let usage_tooltip = tray_usage_summary_from_state(); let themed = { let state = lock_state(); @@ -1923,6 +2031,14 @@ fn total_widget_width() -> i32 { .unwrap_or(1) } +/// Dev-only switch: a release build must never raise an alarm from cached data. +fn wants_cached_usage(args: &[String]) -> bool { + cfg!(debug_assertions) + && args + .iter() + .any(|argument| argument == "--show-cached-usage") +} + pub fn run() { let run_args: Vec = std::env::args().collect(); let open_dashboard_on_start = run_args.iter().any(|argument| argument == "--dashboard"); @@ -1930,6 +2046,10 @@ pub fn run() { .iter() .any(|argument| argument == "--allow-multiple"); let no_poll = run_args.iter().any(|argument| argument == "--no-poll"); + // Smoke tests start from a saved reading, so they never need real accounts. + let cached_usage = wants_cached_usage(&run_args) + .then(app_settings::load_usage_cache) + .flatten(); unsafe { let _ = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); CURRENT_DPI.store(GetDpiForSystem(), Ordering::Relaxed); @@ -2162,7 +2282,7 @@ pub fn run() { install_channel, providers: settings.enabled_providers(), accounts: settings.accounts.clone(), - data: None, + data: cached_usage.as_ref().map(|cache| cache.data.clone()), poll_interval_ms: settings.poll_interval_ms, retry_count: 0, force_notify_auth_error: false, @@ -2171,8 +2291,12 @@ pub fn run() { settings.enabled_providers().first().unwrap_or_default(), ), auth_watch_snapshot: Vec::new(), - last_poll_ok: false, + last_poll_ok: cached_usage.as_ref().is_some_and(|cache| cache.poll_ok), last_poll_failure: None, + last_update_unix: cached_usage.as_ref().map(|cache| cache.updated_unix), + last_fresh_unix: cached_usage + .as_ref() + .and_then(app_settings::UsageCache::fresh_or_updated_unix), update_status: UpdateStatus::Idle, last_update_check_unix: settings.last_update_check_unix, taskbar_index: settings.taskbar_index, @@ -2191,6 +2315,10 @@ pub fn run() { is_snapped: false, placement_override: settings.placement_override.clone(), floating_card_opacity: settings.floating_card_opacity, + hide_until_hover: settings.hide_until_hover, + edge_display: settings.edge_display, + low_usage_alarm: settings.low_usage_alarm, + alarm: Default::default(), window_state_timer_active: false, custom_theme_enabled, usage_countdown: settings.usage_countdown, @@ -2291,12 +2419,15 @@ pub fn run() { fn render_layered() { refresh_dpi(); sync_custom_mirrors(); - let (hwnd_val, active_theme, usage_data, runtime, mirror_hwnds, desktop_hwnds) = { - let state = lock_state(); - let Some(state) = state.as_ref() else { + let (sounded, hwnd_val, active_theme, usage_data, runtime, mirror_hwnds, desktop_hwnds) = { + let mut state = lock_state(); + let Some(state) = state.as_mut() else { return; }; + // Every render brings the alarm up to date first, so `alarm.*` + // always describes the usage being drawn. ( + update_low_usage_alarm(state), state.hwnd, effective_theme_from_state(state), state.data.clone(), @@ -2305,6 +2436,9 @@ fn render_layered() { state.desktop_hwnds.clone(), ) }; + if sounded { + low_usage_alarm::play_sound(); + } // Theme rendering is the widget renderer. Startup and theme changes always // install Classic in memory when a selected theme cannot be loaded. @@ -2312,6 +2446,7 @@ fn render_layered() { let hwnd = hwnd_val.to_hwnd(); set_window_state_timer(hwnd, theme_has_floating_surface(&theme)); let target_count = theme.surfaces.len(); + let mut auto_hiding = Vec::new(); for surface_index in 0..target_count { let regular_hwnd = if surface_index == 0 { hwnd @@ -2382,6 +2517,35 @@ fn render_layered() { ); positioned.placement.offset_x = placement.offset_x; positioned.placement.offset_y = placement.offset_y; + if let Some(edge) = positioned.placement.auto_hide_edge() { + let target = positioning::surface_target(&positioned, scale); + if let Some((rect, _)) = target { + native_interop::make_popup(target_hwnd, true); + let handle = theme_engine::render_surface_handle( + &theme, + surface_index, + usage_data.as_ref(), + surface_runtime, + scale, + edge, + ); + let layout = auto_hide::layout(rect, edge, (handle.width, handle.height), scale); + let force_reveal = theme_engine::surface_force_reveal( + &theme, + surface_index, + usage_data.as_ref(), + surface_runtime, + ); + auto_hide::present(target_hwnd, layout, scale, rendered, handle, force_reveal); + auto_hiding.push(target_hwnd); + } + auto_hide::show( + target_hwnd, + target.is_some() + && !foreground_is_fullscreen_on_display(positioned.placement.reference.display), + ); + continue; + } position_custom_theme(target_hwnd, &positioned, scale); if desktop_nested { unsafe { @@ -2404,6 +2568,8 @@ fn render_layered() { let _ = ShowWindow(target, SW_HIDE); } } + auto_hide::retain(&auto_hiding); + auto_hide::schedule(hwnd); } fn theme_for_surface(theme: &ThemeDocument, surface_index: usize) -> ThemeDocument { let mut result = theme.clone(); @@ -2540,7 +2706,7 @@ fn do_poll_once(hwnd: HWND) { previous.as_ref(), force, |update| { - let cache_data = { + let (cache_data, cache_fresh) = { let mut state = lock_state(); let Some(state) = state.as_mut() else { return; @@ -2549,19 +2715,12 @@ fn do_poll_once(hwnd: HWND) { if state.providers != enabled_providers || state.accounts != accounts { return; } - let data = poller::merge_poll_progress( - update, - &state.data.clone().unwrap_or_default(), - &accounts, - ); - state.data = Some(data.clone()); - state.last_poll_ok = true; - state.last_poll_failure = None; - data + let data = state.apply_progress(update, &accounts); + (data, state.last_fresh_unix) }; // The dashboard runs separately and follows the same cache as the // widget. Publish before waiting for slower providers to finish. - if let Err(error) = app_settings::save_usage_cache(&cache_data, true) { + if let Err(error) = app_settings::save_usage_cache(&cache_data, true, cache_fresh) { diagnose::log_error("unable to save partial usage cache", error); } unsafe { @@ -2578,6 +2737,7 @@ fn do_poll_once(hwnd: HWND) { { return; } + let polled_fresh = data.has_fresh_reading(); let mut data = match state.as_ref().and_then(|s| s.data.as_ref()) { Some(previous) => poller::carry_forward_failures(data, previous, enabled_providers), None => data, @@ -2593,6 +2753,7 @@ fn do_poll_once(hwnd: HWND) { .map(|state| state.language) .unwrap_or(LanguageId::English); let cache_data = data.clone(); + let mut cache_fresh = None; if let Some(s) = state.as_mut() { // Stop fast-poll if reset data is now fresh if !poller::app_is_past_reset(&data) { @@ -2601,9 +2762,14 @@ fn do_poll_once(hwnd: HWND) { } } + if polled_fresh { + s.last_fresh_unix = Some(now_unix_secs()); + } + cache_fresh = s.last_fresh_unix; s.data = Some(data); s.last_poll_ok = true; s.last_poll_failure = None; + s.last_update_unix = Some(now_unix_secs()); // Recovered from errors — restore normal poll interval if s.retry_count > 0 { @@ -2620,7 +2786,7 @@ fn do_poll_once(hwnd: HWND) { s.auth_watch_snapshot.clear(); } drop(state); - match app_settings::save_usage_cache(&cache_data, true) { + match app_settings::save_usage_cache(&cache_data, true, cache_fresh) { Ok(()) => diagnose::log_lazy(|| { format!( "usage cache saved: accounts={} elapsed_ms={}", @@ -2679,7 +2845,7 @@ fn do_poll_once(hwnd: HWND) { | poller::PollError::HttpStatus(_) => None, }; // Distinguish auth-required errors from transient errors. - let (notify_auth_error, cache_data, cache_poll_ok) = { + let (notify_auth_error, cache_data, cache_poll_ok, cache_fresh) = { let mut state = lock_state(); if state .as_ref() @@ -2753,12 +2919,13 @@ fn do_poll_once(hwnd: HWND) { ) .0 }); - (should_notify, cache_data, cache_poll_ok) + let cache_fresh = state.as_ref().and_then(|state| state.last_fresh_unix); + (should_notify, cache_data, cache_poll_ok, cache_fresh) }; // Theme Studio is a separate process and follows this cache. A // transient failure with usable stale data remains displayable; // hard failures and failures without a reading stay errors. - let _ = app_settings::save_usage_cache(&cache_data, cache_poll_ok); + let _ = app_settings::save_usage_cache(&cache_data, cache_poll_ok, cache_fresh); if notify_auth_error { let balloon = { @@ -2900,6 +3067,9 @@ fn reload_external_settings(hwnd: HWND) { settings.tray_offset, ); state.floating_card_opacity = settings.floating_card_opacity; + state.hide_until_hover = settings.hide_until_hover; + state.edge_display = settings.edge_display; + state.low_usage_alarm = settings.low_usage_alarm; state.lock_taskbar = settings.lock_taskbar; lock_enabled = state.lock_taskbar; apply_language_to_state(state, language_override); @@ -3013,7 +3183,9 @@ fn tray_reposition_is_suppressed() -> bool { } } +mod auto_hide; mod host_geometry; +mod low_usage_alarm; mod message_loop; use host_geometry::*; use message_loop::wnd_proc; @@ -3195,6 +3367,13 @@ mod poll_display_state_tests { [(ProviderId::Claude, usage)].into_iter().collect() } + #[test] + fn cached_usage_switch_is_dev_only() { + let args = ["app".to_string(), "--show-cached-usage".to_string()]; + assert_eq!(wants_cached_usage(&args), cfg!(debug_assertions)); + assert!(!wants_cached_usage(&args[..1])); + } + #[test] fn transient_failure_keeps_a_stale_reading_displayable() { let data = cached_usage(true); diff --git a/src/window/auto_hide.rs b/src/window/auto_hide.rs new file mode 100644 index 00000000..53e0b4cd --- /dev/null +++ b/src/window/auto_hide.rs @@ -0,0 +1,501 @@ +//! Hide-until-hover presentation for floating surfaces docked to a monitor's +//! top or bottom edge. +//! +//! A collapsed surface shows only a slim handle, which is click-through so it +//! never blocks whatever is underneath. Click-through windows receive no mouse +//! messages, so the pointer is polled on a window timer: a few microseconds per +//! tick, unlike a global low-level mouse hook that would sit in the input path +//! of every application. The full surface is rendered once per data change and +//! slid in by presenting a growing slice of that bitmap, so animation frames +//! never re-run the theme renderer. + +use super::*; + +/// The pointer must rest on the handle this long, so a cursor flying past +/// along the screen edge does not open the surface. +pub(super) const INTENT_DELAY: Duration = Duration::from_millis(80); +/// How long the pointer may be away before the surface slides back. +pub(super) const LEAVE_DELAY: Duration = Duration::from_millis(600); +pub(super) const REVEAL_DURATION: Duration = Duration::from_millis(250); +pub(super) const HIDE_DURATION: Duration = Duration::from_millis(200); +const FRAME_INTERVAL_MS: u32 = 16; +pub(super) const POLL_INTERVAL_MS: u32 = 40; +/// Logical size of the pointer target around the handle. +const ZONE_WIDTH: f64 = 200.0; +const ZONE_DEPTH: f64 = 6.0; +/// Logical slack around an open surface before the pointer counts as gone. +const LEAVE_SLACK: f64 = 6.0; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Pointer { + pub over_handle: bool, + pub over_surface: bool, + pub button_down: bool, + /// The surface's `force_reveal` expression holds it open, wherever the + /// pointer is. + pub force_reveal: bool, +} + +/// Reveal state machine. `progress` is linear time from 0 (collapsed) to 1 +/// (open); `eased` turns it into the visible fraction. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct Reveal { + pub open: bool, + pub progress: f64, + hover_since: Option, + away_since: Option, + last_tick: Option, +} + +impl Reveal { + pub fn tick(&mut self, now: Instant, pointer: Pointer) { + let was_open = self.open; + if !self.open { + if pointer.force_reveal { + self.open = true; + self.hover_since = None; + self.away_since = None; + // A held button means a window is being dragged to the edge. + } else if pointer.over_handle && !pointer.button_down { + let since = *self.hover_since.get_or_insert(now); + if now.duration_since(since) >= INTENT_DELAY { + self.open = true; + self.hover_since = None; + self.away_since = None; + } + } else { + self.hover_since = None; + } + } else if pointer.force_reveal || pointer.over_surface || pointer.over_handle { + self.away_since = None; + } else { + let since = *self.away_since.get_or_insert(now); + if now.duration_since(since) >= LEAVE_DELAY { + self.open = false; + self.away_since = None; + } + } + // A slide starts on the tick that decides it; time before that + // decision must not make the first frame jump. + let elapsed = self + .last_tick + .filter(|_| was_open == self.open) + .map_or(0.0, |last| now.duration_since(last).as_secs_f64()); + self.last_tick = Some(now); + self.progress = if self.open { + (self.progress + elapsed / REVEAL_DURATION.as_secs_f64()).min(1.0) + } else { + (self.progress - elapsed / HIDE_DURATION.as_secs_f64()).max(0.0) + }; + } + + /// Visible fraction: decelerates into place when opening and accelerates + /// away when closing. + pub fn eased(&self) -> f64 { + 1.0 - (1.0 - self.progress.clamp(0.0, 1.0)).powi(3) + } + + /// Whether the next tick should come at animation rate. + pub fn busy(&self) -> bool { + (self.open && self.progress < 1.0) + || (!self.open && self.progress > 0.0) + || self.hover_since.is_some() + } +} + +/// Physical screen geometry of one auto-hiding surface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Layout { + pub edge: theme_engine::SurfaceEdge, + /// The fully revealed surface. + pub surface: RECT, + /// The collapsed tab. + pub handle: RECT, + /// Where resting the pointer reveals the surface. + pub zone: RECT, +} + +pub(super) fn layout( + surface: RECT, + edge: theme_engine::SurfaceEdge, + handle: (u32, u32), + scale: f64, +) -> Layout { + let width = surface.right - surface.left; + let center = surface.left + width / 2; + let span = |length: i32| { + let length = length.clamp(1, width.max(1)); + let left = center - length / 2; + (left, left + length) + }; + let at_edge = |depth: i32, (left, right): (i32, i32)| match edge { + theme_engine::SurfaceEdge::Top => RECT { + left, + top: surface.top, + right, + bottom: surface.top + depth, + }, + theme_engine::SurfaceEdge::Bottom => RECT { + left, + top: surface.bottom - depth, + right, + bottom: surface.bottom, + }, + }; + Layout { + edge, + surface, + handle: at_edge(handle.1 as i32, span(handle.0 as i32)), + zone: at_edge( + (ZONE_DEPTH * scale).round().max(1.0) as i32, + span((ZONE_WIDTH * scale).round() as i32), + ), + } +} + +/// What one presentation shows: the handle, or a slice of the surface that +/// is anchored to the edge and grows away from it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Frame { + pub window: RECT, + pub handle: bool, + /// First bitmap row shown when presenting a surface slice. + pub source_top: i32, + pub opacity: u8, + /// Only a fully open surface takes clicks; everything else passes them on. + pub interactive: bool, +} + +pub(super) fn frame(layout: &Layout, eased: f64) -> Frame { + let height = layout.surface.bottom - layout.surface.top; + let visible = ((eased.clamp(0.0, 1.0) * height as f64).round() as i32).min(height); + if visible <= 0 { + return Frame { + window: layout.handle, + handle: true, + source_top: 0, + opacity: 255, + interactive: false, + }; + } + let (top, source_top) = match layout.edge { + theme_engine::SurfaceEdge::Top => (layout.surface.top, height - visible), + theme_engine::SurfaceEdge::Bottom => (layout.surface.bottom - visible, 0), + }; + Frame { + window: RECT { + top, + bottom: top + visible, + ..layout.surface + }, + handle: false, + source_top, + opacity: ((0.35 + 0.65 * eased.clamp(0.0, 1.0)) * 255.0).round() as u8, + interactive: visible == height, + } +} + +pub(super) fn pointer(layout: &Layout, cursor: POINT, button_down: bool, scale: f64) -> Pointer { + let contains = |rect: RECT| { + cursor.x >= rect.left + && cursor.x < rect.right + && cursor.y >= rect.top + && cursor.y < rect.bottom + }; + let slack = (LEAVE_SLACK * scale).round() as i32; + Pointer { + over_handle: contains(layout.zone), + over_surface: contains(RECT { + left: layout.surface.left - slack, + top: layout.surface.top - slack, + right: layout.surface.right + slack, + bottom: layout.surface.bottom + slack, + }), + button_down, + force_reveal: false, + } +} + +struct Surface { + hwnd: SendHwnd, + layout: Layout, + scale: f64, + image: std::sync::Arc, + handle: std::sync::Arc, + /// The theme holds the surface open, for example during an alarm. + force_reveal: bool, + reveal: Reveal, + shown: Option, +} + +// Never call Win32 while holding this lock: style and position changes can +// synchronously re-enter the window procedure. +static SURFACES: Mutex> = Mutex::new(Vec::new()); +/// Keeps open surfaces open while a context menu they launched is showing. +static HOLD_OPEN: AtomicBool = AtomicBool::new(false); + +fn surfaces() -> MutexGuard<'static, Vec> { + SURFACES.lock().unwrap_or_else(|error| error.into_inner()) +} + +pub(super) fn owns(hwnd: HWND) -> bool { + surfaces() + .iter() + .any(|surface| surface.hwnd.to_hwnd() == hwnd) +} + +pub(super) fn hold_open(hold: bool) { + HOLD_OPEN.store(hold, Ordering::Release); +} + +/// Install freshly rendered pixels and geometry for an auto-hiding surface, +/// keeping its reveal state, and present it in that state. A forced surface +/// slides open on the next tick and stays open until it is no longer forced. +pub(super) fn present( + hwnd: HWND, + layout: Layout, + scale: f64, + image: theme_engine::RenderedTheme, + handle: theme_engine::RenderedTheme, + force_reveal: bool, +) { + let (image, handle) = (std::sync::Arc::new(image), std::sync::Arc::new(handle)); + let frame = { + let mut surfaces = surfaces(); + let reveal = surfaces + .iter() + .position(|surface| surface.hwnd.to_hwnd() == hwnd) + .map(|index| surfaces.swap_remove(index).reveal) + .unwrap_or_default(); + let frame = frame(&layout, reveal.eased()); + surfaces.push(Surface { + hwnd: SendHwnd::from_hwnd(hwnd), + layout, + scale, + image: image.clone(), + handle: handle.clone(), + force_reveal, + reveal, + shown: Some(frame), + }); + frame + }; + apply(hwnd, &image, &handle, frame); +} + +/// Forget surfaces that are no longer auto-hiding and restore their input. +pub(super) fn retain(active: &[HWND]) { + let removed = { + let mut surfaces = surfaces(); + let mut removed = Vec::new(); + surfaces.retain(|surface| { + let keep = active.contains(&surface.hwnd.to_hwnd()); + if !keep { + removed.push(surface.hwnd); + } + keep + }); + removed + }; + for hwnd in removed { + set_click_through(hwnd.to_hwnd(), false); + } +} + +/// How often the pointer poll should run, or `None` when no surface can +/// react to the pointer: every auto-hiding surface is hidden (for example +/// behind a fullscreen app) and already collapsed. Whatever shows one again +/// calls `schedule`. +pub(super) fn poll_interval() -> Option { + let (windows, busy, dirty) = { + let surfaces = surfaces(); + ( + surfaces + .iter() + .map(|surface| surface.hwnd.to_hwnd()) + .collect::>(), + surfaces.iter().any(|surface| surface.reveal.busy()), + // A hidden surface that is not collapsed yet needs one more tick. + surfaces + .iter() + .any(|surface| surface.reveal != Reveal::default()), + ) + }; + let visible = windows.into_iter().any(is_shown); + if !visible && !dirty { + return None; + } + Some(if busy { + FRAME_INTERVAL_MS + } else { + POLL_INTERVAL_MS + }) +} + +/// Run or stop the pointer poll that drives every auto-hiding surface. +pub(super) fn schedule(owner: HWND) { + unsafe { + match poll_interval() { + Some(interval) => { + SetTimer(Some(owner), TIMER_AUTO_HIDE, interval, None); + } + None => { + let _ = KillTimer(Some(owner), TIMER_AUTO_HIDE); + } + } + } +} + +/// Whether the poll timer is armed, which stops it: for tests that follow +/// the real timer rather than the interval `schedule` would pick. +#[cfg(test)] +pub(super) fn take_timer(owner: HWND) -> bool { + unsafe { KillTimer(Some(owner), TIMER_AUTO_HIDE).is_ok() } +} + +/// The pointer's screen position, when known, and whether the primary button +/// is held. +#[cfg(not(test))] +fn pointer_state() -> (Option, bool) { + use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON, VK_RBUTTON}; + let mut cursor = POINT::default(); + let cursor_known = unsafe { GetCursorPos(&mut cursor) }.is_ok(); + let button_down = unsafe { + // GetAsyncKeyState reads physical buttons; follow a swapped mouse. + let primary = if GetSystemMetrics(SM_SWAPBUTTON) != 0 { + VK_RBUTTON + } else { + VK_LBUTTON + }; + GetAsyncKeyState(primary.0 as i32) as u16 & 0x8000 != 0 + }; + (cursor_known.then_some(cursor), button_down) +} + +#[cfg(not(test))] +pub(super) fn is_shown(hwnd: HWND) -> bool { + unsafe { IsWindowVisible(hwnd).as_bool() } +} + +/// Show or hide an auto-hiding surface's window. +#[cfg(not(test))] +pub(super) fn show(hwnd: HWND, show: bool) { + unsafe { + let _ = ShowWindow(hwnd, if show { SW_SHOWNOACTIVATE } else { SW_HIDE }); + } +} + +// Tests stand in for the desktop: surfaces are only marked shown, never put on +// screen, and the pointer is nowhere. +#[cfg(test)] +thread_local! { + static TEST_SHOWN: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +fn pointer_state() -> (Option, bool) { + (None, false) +} + +#[cfg(test)] +pub(super) fn is_shown(hwnd: HWND) -> bool { + TEST_SHOWN.with(|shown| shown.borrow().contains(&(hwnd.0 as isize))) +} + +#[cfg(test)] +pub(super) fn show(hwnd: HWND, show: bool) { + TEST_SHOWN.with(|shown| { + let mut shown = shown.borrow_mut(); + shown.retain(|window| *window != hwnd.0 as isize); + if show { + shown.push(hwnd.0 as isize); + } + }); +} + +pub(super) fn tick(owner: HWND) { + let (cursor, button_down) = pointer_state(); + let held = HOLD_OPEN.load(Ordering::Acquire); + let now = Instant::now(); + let windows: Vec<_> = surfaces() + .iter() + .map(|surface| surface.hwnd.to_hwnd()) + .collect(); + // Hidden surfaces (for example behind a fullscreen app) stay collapsed. + let visible: Vec<_> = windows.iter().map(|hwnd| is_shown(*hwnd)).collect(); + let updates = { + let mut surfaces = surfaces(); + let mut updates = Vec::new(); + for surface in surfaces.iter_mut() { + let hwnd = surface.hwnd.to_hwnd(); + let shown = windows + .iter() + .position(|window| *window == hwnd) + .is_some_and(|index| visible[index]); + if shown { + let mut input = cursor.map_or_else(Pointer::default, |cursor| { + pointer(&surface.layout, cursor, button_down, surface.scale) + }); + input.over_surface |= held && surface.reveal.open; + input.force_reveal = surface.force_reveal; + surface.reveal.tick(now, input); + } else { + // Collapse at once so the surface never reappears open. + surface.reveal = Reveal::default(); + } + let frame = frame(&surface.layout, surface.reveal.eased()); + if surface.shown != Some(frame) { + surface.shown = Some(frame); + updates.push((hwnd, surface.image.clone(), surface.handle.clone(), frame)); + } + } + updates + }; + for (hwnd, image, handle, frame) in updates { + apply(hwnd, &image, &handle, frame); + } + schedule(owner); +} + +fn apply( + hwnd: HWND, + image: &theme_engine::RenderedTheme, + handle: &theme_engine::RenderedTheme, + frame: Frame, +) { + set_click_through(hwnd, !frame.interactive); + let origin = POINT { + x: frame.window.left, + y: frame.window.top, + }; + let (bitmap, source_top) = if frame.handle { + (handle, 0) + } else { + (image, frame.source_top) + }; + positioning::present_layered( + hwnd, + bitmap, + positioning::LayeredFrame { + source_top, + height: frame.window.bottom - frame.window.top, + origin: Some(origin), + opacity: frame.opacity, + interactive: frame.interactive, + }, + ); +} + +fn set_click_through(hwnd: HWND, click_through: bool) { + unsafe { + let style = GetWindowLongW(hwnd, GWL_EXSTYLE); + let wanted = if click_through { + style | WS_EX_TRANSPARENT.0 as i32 + } else { + style & !(WS_EX_TRANSPARENT.0 as i32) + }; + if wanted != style { + let _ = SetWindowLongW(hwnd, GWL_EXSTYLE, wanted); + } + } +} diff --git a/src/window/low_usage_alarm.rs b/src/window/low_usage_alarm.rs new file mode 100644 index 00000000..3f3067fc --- /dev/null +++ b/src/window/low_usage_alarm.rs @@ -0,0 +1,673 @@ +//! The low-usage alarm: a sound when a quota becomes almost spent, and +//! the `alarm.*` theme bindings that keep edge-docked surfaces revealed. + +use std::collections::BTreeMap; +use std::time::{Duration, Instant, SystemTime}; + +use crate::theme_engine::AlarmScan; + +/// How long "Snooze alarm (1 hour)" lets surfaces hide again. +pub(super) const SNOOZE: Duration = Duration::from_secs(60 * 60); + +/// How far past its reset time a stored crossing survives, so a slightly fast +/// local clock does not drop it the instant the reset passes. +const RESET_GRACE: Duration = Duration::from_secs(5 * 60); + +/// The alarm sounds at most this often, so providers finishing a few seconds +/// apart do not each play it. +const SOUND_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, Default)] +pub(super) struct LowUsageAlarm { + /// Quotas that crossed in and have not recovered, with their source and + /// the reset time last seen. Independent of the theme and of which + /// account is selected. + crossed: BTreeMap)>, + snoozed_until: Option, + /// Monotonic, so moving the clock back cannot sound the alarm early. + last_sound: Option, +} + +impl LowUsageAlarm { + /// Take the latest readings (`now` is wall-clock time for quota reset + /// times, `mono` measures the sound spacing). Returns whether the sound + /// should play: only when a quota newly crosses in, and at most once a + /// minute even when several limits cross. A crossing ends any snooze. A + /// fresh low reading always counts. A stale one (kept by the poller from + /// a failed refresh) never creates a crossing and only keeps one until + /// its own or the stored reset time has passed (with a grace for clock + /// skew). A stored crossing also clears when a good reading shows the + /// quota above the zone. A missing reading, a failed poll or a theme or + /// account switch is no evidence; a source gone from the data (account + /// removed, provider disabled) is dropped. A reading without a reset time + /// keeps the last one seen. + pub fn update(&mut self, scan: &AlarmScan, now: SystemTime, mono: Instant) -> bool { + let mut crossed = false; + let mut confirmed = std::collections::BTreeSet::new(); + let expired = |at: Option| at.is_some_and(|at| now >= at + RESET_GRACE); + for reading in &scan.readings { + if reading.low && !reading.stale { + let previous = self.crossed.get(&reading.key).and_then(|(_, at)| *at); + let record = (reading.owner.clone(), reading.resets_at.or(previous)); + crossed |= self.crossed.insert(reading.key.clone(), record).is_none(); + confirmed.insert(reading.key.as_str()); + } else if !(reading.low + && self.crossed.contains_key(&reading.key) + && !expired(reading.resets_at)) + { + // A stale low reading keeps an existing record (below) only + // while its own reset has not passed. + self.crossed.remove(&reading.key); + } + } + self.crossed.retain(|key, (owner, at)| { + scan.owners.contains(owner) && (confirmed.contains(key.as_str()) || !expired(*at)) + }); + if crossed || self.crossed.is_empty() { + self.snoozed_until = None; + } + // Crossings in quick succession (providers publish one by one) still + // update the state above, but share one sound. + let sound = crossed + && self + .last_sound + .is_none_or(|at| mono.saturating_duration_since(at) >= SOUND_INTERVAL); + if sound { + self.last_sound = Some(mono); + } + sound + } + + /// Forget crossings and snooze, as when the alarm is turned off. The + /// sound spacing stays, so switching off and on cannot dodge it. + pub fn clear(&mut self) { + *self = Self { + last_sound: self.last_sound, + ..Self::default() + }; + } + + /// Let surfaces hide for an hour while the current limits stay low. + pub fn snooze(&mut self, now: Instant) { + if !self.crossed.is_empty() { + self.snoozed_until = now.checked_add(SNOOZE); + } + } + + pub fn snoozed(&self, now: Instant) -> bool { + !self.crossed.is_empty() && self.snoozed_until.is_some_and(|until| now < until) + } + + /// A limit is almost spent and the alarm is not snoozed. + pub fn active(&self, now: Instant) -> bool { + !self.crossed.is_empty() && !self.snoozed(now) + } + + /// When an active snooze runs out, so the surface can be revealed again. + pub fn snooze_remaining(&self, now: Instant) -> Option { + self.snoozed_until + .filter(|_| self.snoozed(now)) + .map(|until| until - now) + } +} + +/// The alarm sound, synthesized for this project (see `src/assets/README.md`). +/// Static, so it outlives the asynchronous playback. +static ALARM_WAV: &[u8] = include_bytes!("../assets/alarm-warning-pulse.wav"); + +/// What the alarm asks the system to play. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Source { + /// The bundled sound, played from memory. + Embedded(&'static [u8]), + /// The sound scheme's "Alarm 1", else its "Critical Stop". + Scheme, +} + +/// Play the alarm once, without blocking: the bundled sound, and only if that +/// fails the sound scheme's alarm. A scheme with neither sound, such as "No +/// Sounds", keeps the alarm silent, as it always has. +pub(super) fn play_sound() { + let played = emit(Source::Embedded(ALARM_WAV)) || emit(Source::Scheme); + #[cfg(not(test))] + crate::diagnose::log(format!("low-usage alarm sounded: {played}")); + #[cfg(test)] + let _ = played; +} + +// The windows crate binds PlaySound only under its Media feature. +#[cfg(not(test))] +#[link(name = "winmm")] +extern "system" { + fn PlaySoundW(sound: windows::core::PCWSTR, module: *mut std::ffi::c_void, flags: u32) -> i32; +} + +/// Whether the active sound scheme has a sound for this event. +#[cfg(not(test))] +fn scheme_has_sound(event: &str) -> bool { + use windows::core::PCWSTR; + use windows::Win32::System::Registry::*; + let path = crate::native_interop::wide_str(&format!( + r"AppEvents\Schemes\Apps\.Default\{event}\.Current" + )); + unsafe { + let mut hkey = HKEY::default(); + if RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR(path.as_ptr()), + None, + KEY_READ, + &mut hkey, + ) + .is_err() + { + return false; + } + // The default value is the .wav path; empty (just a terminator) = "(None)". + let mut size = 0u32; + let result = RegQueryValueExW(hkey, PCWSTR::null(), None, None, None, Some(&mut size)); + let _ = RegCloseKey(hkey); + result.is_ok() && size > std::mem::size_of::() as u32 + } +} + +/// The far boundary: ask the system to play one source. True when the alarm +/// is handled, including a scheme the user silenced on purpose. +#[cfg(not(test))] +fn emit(source: Source) -> bool { + const SND_ASYNC: u32 = 0x0001; + const SND_NODEFAULT: u32 = 0x0002; + const SND_MEMORY: u32 = 0x0004; + const SND_ALIAS: u32 = 0x0001_0000; + const SND_SYSTEM: u32 = 0x0020_0000; + match source { + Source::Embedded(bytes) => { + // "No Sounds": the user silenced Windows sounds, so stay quiet. + if !scheme_has_sound("Notification.Looping.Alarm") && !scheme_has_sound("SystemHand") { + return true; + } + unsafe { + PlaySoundW( + windows::core::PCWSTR(bytes.as_ptr().cast()), + std::ptr::null_mut(), + SND_MEMORY | SND_ASYNC | SND_NODEFAULT, + ) != 0 + } + } + Source::Scheme => ["Notification.Looping.Alarm", "SystemHand"] + .into_iter() + .any(|alias| { + let alias = crate::native_interop::wide_str(alias); + unsafe { + PlaySoundW( + windows::core::PCWSTR(alias.as_ptr()), + std::ptr::null_mut(), + SND_ALIAS | SND_ASYNC | SND_NODEFAULT | SND_SYSTEM, + ) != 0 + } + }), + } +} + +#[cfg(test)] +thread_local! { + /// Tests count alarms instead of making noise. + pub(super) static SOUNDED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// What the last alarm asked the system to play. + static LAST_SOURCE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn emit(source: Source) -> bool { + SOUNDED.with(|count| count.set(count.get() + 1)); + LAST_SOURCE.with(|last| last.set(Some(source))); + true +} + +/// Tests drive the wall clock; this maps it onto a monotonic clock that moves +/// the same amount. +#[cfg(test)] +pub(super) fn mono_for(now: SystemTime) -> Instant { + static BASE: std::sync::OnceLock<(SystemTime, Instant)> = std::sync::OnceLock::new(); + let (system, mono) = *BASE.get_or_init(|| (SystemTime::now(), Instant::now())); + mono + now.duration_since(system).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn up(alarm: &mut LowUsageAlarm, scan: &AlarmScan, now: SystemTime) -> bool { + alarm.update(scan, now, mono_for(now)) + } + + use crate::models::{AccountUsage, AppUsageData, UsageData, UsageLimit, UsageSection}; + use crate::providers::{ProviderId, ProviderSet}; + + fn section(used: f64, resets_at: Option) -> UsageSection { + UsageSection { + available: true, + percentage: used, + resets_at, + } + } + + /// One Claude account with a weekly reading and an optional Fable cap. + fn account( + id: &str, + weekly: f64, + fable: Option, + resets_at: Option, + ) -> AccountUsage { + AccountUsage { + provider: ProviderId::Claude, + profile: crate::accounts::AccountProfile { + id: id.into(), + name: id.into(), + enabled: true, + ..Default::default() + }, + source_signature: String::new(), + source_path: None, + usage: Some(UsageData { + weekly: section(weekly, resets_at), + limits: fable + .map(|used| UsageLimit { + key: "weekly_scoped_fable".into(), + kind: "weekly_scoped".into(), + model: Some("Fable".into()), + usage: section(used, resets_at), + ..Default::default() + }) + .into_iter() + .collect(), + ..Default::default() + }), + error: None, + selected: false, + } + } + + fn scan(accounts: Vec) -> AlarmScan { + let mut data = AppUsageData::default(); + data.accounts = accounts; + crate::theme_engine::alarm_scan(&data, ProviderSet::from_enabled([ProviderId::Claude])) + } + + /// The poller kept these readings from an earlier poll. + fn stale_scan(mut accounts: Vec) -> AlarmScan { + for account in &mut accounts { + account.usage.as_mut().unwrap().stale = true; + } + scan(accounts) + } + + fn fable(used: f64) -> AlarmScan { + scan(vec![account("a", 10.0, Some(used), None)]) + } + + #[test] + fn sounds_once_per_crossing_not_on_every_poll() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(!up(&mut alarm, &fable(50.0), now)); + assert!(up(&mut alarm, &fable(96.0), now), "crossing in sounds"); + for _ in 0..5 { + assert!( + !up(&mut alarm, &fable(96.0), now), + "repeat polls stay quiet" + ); + } + // A second quota crossing sounds; leaving is silent. + let both = scan(vec![account("a", 97.0, Some(96.0), None)]); + let later = now + Duration::from_secs(120); + assert!(up(&mut alarm, &both, later)); + assert!(!up(&mut alarm, &fable(96.0), later)); + } + + #[test] + fn a_steady_low_quota_stays_silent_across_theme_and_account_switches() { + let now = SystemTime::now(); + let low = |ids: &[&str]| { + scan( + ids.iter() + .map(|id| account(id, 10.0, Some(96.0), None)) + .collect(), + ) + }; + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &fable(96.0), now)); + alarm.snooze(Instant::now()); + // The theme never reaches the alarm: renders with any theme (Top Bar, + // Classic, Top Bar) feed it the same scan. + for _ in 0..3 { + assert!(!up(&mut alarm, &fable(96.0), now)); + } + assert!( + alarm.snoozed(Instant::now()), + "theme switches keep the snooze" + ); + // Accounts A -> B -> A, both low: B's own crossing sounds once, and + // switching back is silent. + assert!(up( + &mut alarm, + &low(&["a", "b"]), + now + Duration::from_secs(120) + )); + alarm.snooze(Instant::now()); + for ids in [["a", "b"], ["b", "a"], ["a", "b"]] { + assert!(!up(&mut alarm, &low(&ids), now)); + } + assert!(alarm.snoozed(Instant::now())); + } + + #[test] + fn a_poll_gap_or_failed_poll_is_not_a_recovery() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &fable(96.0), now)); + alarm.snooze(Instant::now()); + // The account reports nothing (failed poll): still the same crossing. + let mut failed = account("a", 10.0, Some(96.0), None); + failed.usage = None; + failed.error = Some(crate::poller::PollError::NetworkError); + assert!(!up(&mut alarm, &scan(vec![failed]), now)); + assert!(alarm.snoozed(Instant::now()) && !alarm.crossed.is_empty()); + // A reading without that cap at all (quota missing) is no recovery either. + assert!(!up( + &mut alarm, + &scan(vec![account("a", 10.0, None, None)]), + now + )); + assert!( + !up(&mut alarm, &fable(96.0), now), + "back at 96% stays silent" + ); + assert!(alarm.snoozed(Instant::now())); + } + + #[test] + fn recovery_or_a_passed_reset_lets_the_quota_sound_again() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &fable(96.0), now)); + alarm.snooze(Instant::now()); + // A good reading at 10% used is recovery; low again sounds, snooze gone. + assert!(!up(&mut alarm, &fable(10.0), now)); + assert!(!alarm.snoozed(Instant::now())); + assert!(up(&mut alarm, &fable(96.0), now + Duration::from_secs(120))); + + // The reset time passing is recovery even with no new reading. + let reset = now + Duration::from_secs(60); + let before = scan(vec![account("a", 10.0, Some(96.0), Some(reset))]); + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &before, now)); + alarm.snooze(Instant::now()); + let later = reset + Duration::from_secs(6 * 60); + let next = now + Duration::from_secs(3600); + let gone = AlarmScan { + owners: before.owners.clone(), + ..Default::default() + }; + assert!(!up(&mut alarm, &gone, later), "nothing left to watch"); + assert!(!alarm.active(Instant::now())); + let again = scan(vec![account("a", 10.0, Some(96.0), Some(next))]); + assert!( + up(&mut alarm, &again, later), + "low again after the reset sounds" + ); + } + + #[test] + fn a_fresh_low_reading_counts_with_a_reset_just_behind_the_clock() { + let now = SystemTime::now(); + let behind = now - Duration::from_secs(2 * 60); + let skewed = || scan(vec![account("a", 10.0, Some(96.0), Some(behind))]); + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &skewed(), now), "eligible on its own"); + assert!(alarm.active(Instant::now())); + // Still confirmed low each poll: held, and silent after the first. + assert!(!up(&mut alarm, &skewed(), now)); + assert!(alarm.active(Instant::now())); + } + + #[test] + fn a_carried_forward_reading_past_its_reset_expires_and_a_new_reset_crosses_again() { + let now = SystemTime::now(); + let reset = now + Duration::from_secs(60); + let cached = stale_scan(vec![account("a", 10.0, Some(96.0), Some(reset))]); + let mut alarm = LowUsageAlarm::default(); + assert!( + up( + &mut alarm, + &scan(vec![account("a", 10.0, Some(96.0), Some(reset))]), + now + ), + "fresh" + ); + // A failed poll leaves the same cached reading in the scan. + let stale = reset + Duration::from_secs(6 * 60); + assert!(!up(&mut alarm, &cached, stale)); + assert!(!alarm.active(Instant::now()), "stale reading keeps nothing"); + assert!(alarm.crossed.is_empty()); + let fresh = scan(vec![account( + "a", + 10.0, + Some(96.0), + Some(stale + Duration::from_secs(3600)), + )]); + assert!(up(&mut alarm, &fresh, stale), "a new reset crosses again"); + assert!(alarm.active(Instant::now())); + } + + #[test] + fn a_stale_low_reading_never_creates_and_expires_without_a_reset_time() { + let now = SystemTime::now(); + let reset = now + Duration::from_secs(60); + let mut alarm = LowUsageAlarm::default(); + let stale_new = stale_scan(vec![account("a", 10.0, Some(96.0), None)]); + assert!(!up(&mut alarm, &stale_new, now), "stale never crosses in"); + assert!(alarm.crossed.is_empty()); + let fresh = scan(vec![account("a", 10.0, Some(96.0), Some(reset))]); + assert!(up(&mut alarm, &fresh, now)); + let resetless = stale_scan(vec![account("a", 10.0, Some(96.0), None)]); + let inside = reset + Duration::from_secs(4 * 60); + assert!(!up(&mut alarm, &resetless, inside)); + assert!(alarm.active(Instant::now()), "kept inside the grace"); + let past = reset + Duration::from_secs(6 * 60); + assert!(!up(&mut alarm, &resetless, past)); + assert!(alarm.crossed.is_empty(), "retained reset + grace passed"); + } + + #[test] + fn a_fresh_low_reading_counts_whatever_its_reset_says() { + let now = SystemTime::now(); + let behind = now - Duration::from_secs(6 * 60); + let mut alarm = LowUsageAlarm::default(); + let fresh = scan(vec![account("a", 10.0, Some(97.0), Some(behind))]); + assert!(up(&mut alarm, &fresh, now), "counts and sounds"); + assert!(alarm.active(Instant::now())); + } + + #[test] + fn turning_the_alarm_off_and_on_keeps_the_sound_spacing() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &fable(96.0), now)); + alarm.clear(); + assert!(alarm.crossed.is_empty()); + let soon = now + Duration::from_secs(10); + assert!(!up(&mut alarm, &fable(96.0), soon), "cooldown survives"); + assert!(alarm.active(Instant::now()), "still held open"); + alarm.clear(); + let later = now + Duration::from_secs(70); + assert!(up(&mut alarm, &fable(96.0), later)); + } + + #[test] + fn moving_the_clock_back_does_not_sound_again() { + let now = SystemTime::now(); + let t = Instant::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(alarm.update(&fable(96.0), now, t)); + alarm.clear(); + let back = now - Duration::from_secs(3600); + assert!(!alarm.update(&fable(96.0), back, t + Duration::from_secs(5))); + alarm.clear(); + assert!(alarm.update(&fable(96.0), back, t + Duration::from_secs(61))); + } + + #[test] + fn crossings_within_a_minute_share_one_sound_and_a_later_one_sounds() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &fable(96.0), now)); + alarm.snooze(Instant::now()); + let second = scan(vec![account("a", 97.0, Some(96.0), None)]); + assert!(!up(&mut alarm, &second, now + Duration::from_secs(3))); + assert!(alarm.active(Instant::now()), "still ends the snooze"); + let third = scan(vec![ + account("a", 97.0, Some(96.0), None), + account("b", 98.0, None, None), + ]); + assert!(up(&mut alarm, &third, now + Duration::from_secs(120))); + } + + #[test] + fn a_stored_crossing_survives_a_reset_inside_the_grace_and_expires_after() { + let now = SystemTime::now(); + let reset = now + Duration::from_secs(60); + let low = scan(vec![account("a", 10.0, Some(96.0), Some(reset))]); + let none = AlarmScan { + owners: low.owners.clone(), + ..Default::default() + }; + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &low, now)); + up(&mut alarm, &none, reset + Duration::from_secs(4 * 60)); + assert!(alarm.active(Instant::now()), "inside the grace"); + up(&mut alarm, &none, reset + Duration::from_secs(6 * 60)); + assert!(!alarm.active(Instant::now()), "past the grace"); + } + + #[test] + fn a_reading_without_a_reset_time_keeps_the_last_known_one() { + let now = SystemTime::now(); + let reset = now + Duration::from_secs(60); + let with = scan(vec![account("a", 10.0, Some(96.0), Some(reset))]); + let without = scan(vec![account("a", 10.0, Some(96.0), None)]); + let none = AlarmScan { + owners: with.owners.clone(), + ..Default::default() + }; + let mut alarm = LowUsageAlarm::default(); + assert!(up(&mut alarm, &with, now)); + assert!(!up(&mut alarm, &without, now)); + up(&mut alarm, &none, reset + Duration::from_secs(6 * 60)); + assert!( + !alarm.active(Instant::now()), + "the kept reset time still expires the record" + ); + } + + #[test] + fn several_quotas_crossing_in_one_poll_report_one_sound() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + let many = scan(vec![ + account("a", 97.0, Some(96.0), None), + account("b", 98.0, Some(99.0), None), + ]); + assert!(up(&mut alarm, &many, now), "one sound for the whole poll"); + assert_eq!(alarm.crossed.len(), 4); + assert!(!up(&mut alarm, &many, now)); + } + + #[test] + fn a_non_selected_accounts_low_weekly_alarms_and_removed_sources_are_dropped() { + let now = SystemTime::now(); + let mut alarm = LowUsageAlarm::default(); + let work = account("work", 98.0, None, None); + assert!(up( + &mut alarm, + &scan(vec![account("a", 10.0, None, None), work]), + now + )); + assert!(alarm.active(Instant::now())); + // The account is removed from the settings: nothing holds the bar. + assert!(!up( + &mut alarm, + &scan(vec![account("a", 10.0, None, None)]), + now + )); + assert!(!alarm.active(Instant::now())); + } + + #[test] + fn active_while_a_quota_is_low_and_snooze_lasts_an_hour() { + let now = SystemTime::now(); + let t = Instant::now(); + let mut alarm = LowUsageAlarm::default(); + assert!(!alarm.active(t)); + alarm.snooze(t); + assert!(!alarm.snoozed(t), "nothing to snooze"); + up(&mut alarm, &fable(96.0), now); + assert!(alarm.active(t)); + alarm.snooze(t); + assert!(alarm.snoozed(t) && !alarm.active(t)); + assert_eq!(alarm.snooze_remaining(t), Some(SNOOZE)); + assert!(!alarm.active(t + SNOOZE - Duration::from_secs(1))); + assert!( + alarm.active(t + SNOOZE), + "an hour later it holds the bar again" + ); + assert_eq!(alarm.snooze_remaining(t + SNOOZE), None); + // A different quota crossing during the snooze alarms at once. + alarm.snooze(t); + let later = now + Duration::from_secs(120); + assert!(up( + &mut alarm, + &scan(vec![account("a", 97.0, Some(96.0), None)]), + later + )); + assert!(alarm.active(t) && !alarm.snoozed(t)); + // Once every quota has recovered there is nothing left snoozed. + alarm.snooze(t); + up(&mut alarm, &fable(10.0), now); + assert!(!alarm.snoozed(t) && !alarm.active(t)); + } + + #[test] + fn bundled_sound_is_a_pcm_wav_of_the_expected_size() { + let wav = ALARM_WAV; + assert_eq!(wav.len(), 84_716); + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!( + u32::from_le_bytes(wav[4..8].try_into().unwrap()) as usize, + wav.len() - 8 + ); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(&wav[12..16], b"fmt "); + let u16_at = |at: usize| u16::from_le_bytes(wav[at..at + 2].try_into().unwrap()); + assert_eq!(u16_at(20), 1, "PCM"); + assert_eq!(u16_at(22), 1, "mono"); + assert_eq!( + u32::from_le_bytes(wav[24..28].try_into().unwrap()), + 44_100, + "sample rate" + ); + assert_eq!(u16_at(34), 16, "bits per sample"); + } + + #[test] + fn the_alarm_plays_the_embedded_sound_once() { + SOUNDED.with(|count| count.set(0)); + LAST_SOURCE.with(|last| last.set(None)); + play_sound(); + assert_eq!(SOUNDED.with(std::cell::Cell::get), 1); + match LAST_SOURCE.with(std::cell::Cell::get) { + Some(Source::Embedded(bytes)) => assert!(std::ptr::eq(bytes, ALARM_WAV)), + other => panic!("expected the embedded sound, got {other:?}"), + } + } +} diff --git a/src/window/message_loop.rs b/src/window/message_loop.rs index 960c40d7..124b9127 100644 --- a/src/window/message_loop.rs +++ b/src/window/message_loop.rs @@ -121,6 +121,14 @@ pub(super) unsafe extern "system" fn wnd_proc( TIMER_TRAY_HOVER => { clear_tray_mouse_hover_if_left(hwnd); } + TIMER_AUTO_HIDE => { + auto_hide::tick(hwnd); + } + TIMER_ALARM_SNOOZE => { + // Re-arms itself if it fired before the snooze ran out. + schedule_alarm_snooze_timer(hwnd); + render_layered(); + } TIMER_TRAY_REPOSITION => { let _ = KillTimer(Some(hwnd), TIMER_TRAY_REPOSITION); refresh_theme_host_geometry(); @@ -214,11 +222,13 @@ pub(super) unsafe extern "system" fn wnd_proc( WM_MOUSEMOVE => { let mut pt = POINT::default(); let _ = unsafe { GetCursorPos(&mut pt) }; + // Edge-docked auto-hiding surfaces keep their place; a press only clicks. + let draggable = !auto_hide::owns(hwnd); let (should_start_drag, is_dragging) = { let mut state = lock_state(); if let Some(s) = state.as_mut() { - if s.pending_drag && !s.dragging { + if s.pending_drag && !s.dragging && draggable { let cx_drag = unsafe { GetSystemMetrics(SM_CXDRAG) }; let cy_drag = unsafe { GetSystemMetrics(SM_CYDRAG) }; let dx = (pt.x - s.drag_start_cursor.x).abs(); diff --git a/src/window/mouse.rs b/src/window/mouse.rs index 11398d1e..37d3e820 100644 --- a/src/window/mouse.rs +++ b/src/window/mouse.rs @@ -117,6 +117,7 @@ pub(super) fn execute_mouse_action_source( match effect { MouseActionEffect::ShowDashboard => crate::dashboard::show(owner), MouseActionEffect::ToggleDashboard => crate::dashboard::toggle(owner), + MouseActionEffect::SnoozeAlarm => snooze_low_usage_alarm(owner), MouseActionEffect::OpenUrl(url) => { open_web_url(owner, &url, "mouse action URL could not be opened") } diff --git a/src/window/placement_regression_tests.rs b/src/window/placement_regression_tests.rs index 18487f74..4babb7b4 100644 --- a/src/window/placement_regression_tests.rs +++ b/src/window/placement_regression_tests.rs @@ -1,3 +1,4 @@ +use super::low_usage_alarm::mono_for; use super::*; fn state_for(theme: ThemeDocument, placement: PlacementOverride) -> AppState { @@ -22,6 +23,8 @@ fn state_for(theme: ThemeDocument, placement: PlacementOverride) -> AppState { auth_watch_snapshot: Vec::new(), last_poll_ok: false, last_poll_failure: None, + last_update_unix: None, + last_fresh_unix: None, update_status: UpdateStatus::Idle, last_update_check_unix: None, taskbar_index: 0, @@ -40,6 +43,10 @@ fn state_for(theme: ThemeDocument, placement: PlacementOverride) -> AppState { is_snapped: false, placement_override: Some(placement), floating_card_opacity: None, + hide_until_hover: None, + edge_display: None, + low_usage_alarm: true, + alarm: Default::default(), window_state_timer_active: false, custom_theme_enabled: true, usage_countdown: false, @@ -95,6 +102,73 @@ fn theme_selection_releases_the_previous_roots_temporary_fallback() { } } +#[test] +fn carried_forward_readings_do_not_advance_the_fresh_time() { + let mut state = state_for(ThemeDocument::starter(), placement("taskbar")); + let reading = |stale| { + AppUsageData::from_iter([( + ProviderId::Claude, + crate::models::UsageData { + stale, + ..Default::default() + }, + )]) + }; + state.note_fresh(&reading(true)); + assert_eq!(state.last_fresh_unix, None); + state.note_fresh(&reading(false)); + assert!(state.last_fresh_unix.is_some()); +} + +#[test] +fn failed_accounts_merged_with_old_readings_do_not_advance_the_fresh_time() { + let profile = |id: &str| crate::accounts::AccountProfile { + id: id.into(), + name: id.into(), + enabled: true, + ..Default::default() + }; + let account = |id: &str, ok: bool| crate::models::AccountUsage { + provider: ProviderId::Claude, + profile: profile(id), + source_signature: "sig".into(), + source_path: None, + usage: ok.then(crate::models::UsageData::default), + error: (!ok).then_some(poller::PollError::NetworkError), + selected: false, + }; + let update = |a: bool, b: bool| { + let mut data = AppUsageData::default(); + data.accounts.push(account("a", a)); + data.accounts.push(account("b", b)); + data + }; + let mut settings = crate::accounts::AccountSettings::default(); + settings.claude.profiles = vec![profile("a"), profile("b")]; + settings.claude.selected = "a".into(); + let mut state = state_for(ThemeDocument::starter(), placement("taskbar")); + state.apply_progress(update(true, true), &settings); + let first = state.last_fresh_unix.expect("a successful poll stamps"); + state.last_fresh_unix = Some(first - 600); + // Both fail, delivered one account at a time: old readings are carried + // forward (stale) but the fresh time must not move. + let mut only_a = AppUsageData::default(); + only_a.accounts.push(account("a", false)); + let merged = state.apply_progress(only_a, &settings); + assert_eq!(state.last_fresh_unix, Some(first - 600)); + let mut only_b = AppUsageData::default(); + only_b.accounts.push(account("b", false)); + state.apply_progress(only_b, &settings); + assert_eq!(state.last_fresh_unix, Some(first - 600)); + // The merge still holds another account's old non-stale reading. + assert!(merged.has_fresh_reading()); + // One success moves it. + let mut a_ok = AppUsageData::default(); + a_ok.accounts.push(account("a", true)); + state.apply_progress(a_ok, &settings); + assert!(state.last_fresh_unix.unwrap() > first - 600); +} + fn ejected_state() -> AppState { let mut state = state_for(ThemeDocument::starter(), placement("taskbar")); state.placement_override = None; @@ -1029,3 +1103,607 @@ fn collision_fixture( reserved: tray.into_iter().collect(), } } + +#[test] +fn auto_hiding_roots_ignore_saved_drags_and_follow_display_settings() { + let mut top_bar: ThemeDocument = + serde_json::from_str(include_str!("../themes/top-bar.json")).unwrap(); + top_bar.prepare_runtime(); + let authored = top_bar.surfaces[0].placement.clone(); + // A drag saved under another theme must not pull the bar into the taskbar + // or leave it floating mid-screen. + for nest in ["taskbar", "floating"] { + let mut saved = placement(nest); + saved.screen_x = 400; + saved.screen_y = 300; + let mut state = state_for(top_bar.clone(), saved); + assert_eq!( + effective_theme_from_state(&state).unwrap().surfaces[0].placement, + authored, + "{nest}" + ); + state.edge_display = Some(1); + let moved = effective_theme_from_state(&state).unwrap(); + assert_eq!(moved.surfaces[0].placement.reference.display, 1); + assert_eq!(moved.placement.reference.display, 1); + assert_eq!( + moved.surfaces[0].placement.auto_hide_edge(), + authored.auto_hide_edge() + ); + + // Hiding turned off, it stays at its edge and still ignores the drag. + state.hide_until_hover = Some(false); + let pinned = effective_theme_from_state(&state).unwrap(); + assert!(!pinned.surfaces[0].placement.auto_hide); + assert_eq!(pinned.surfaces[0].placement.edge(), authored.edge()); + assert_eq!( + pinned.surfaces[0].placement.offset_x, authored.offset_x, + "{nest}" + ); + assert_eq!( + pinned.surfaces[0].placement.offset_y, authored.offset_y, + "{nest}" + ); + assert_eq!(pinned.surfaces[0].placement.nest, authored.nest, "{nest}"); + } + // Other themes keep their saved drag placement and ignore the settings. + let mut state = state_for(ThemeDocument::starter(), placement("taskbar")); + let before = effective_theme_from_state(&state).unwrap().surfaces[0] + .placement + .clone(); + state.hide_until_hover = Some(true); + state.edge_display = Some(1); + assert_eq!( + effective_theme_from_state(&state).unwrap().surfaces[0].placement, + before + ); +} + +#[test] +fn an_offset_floating_root_that_never_hides_still_honours_a_saved_drag() { + // The Top Bar's docking, authored without hiding and with an offset. + let mut theme: ThemeDocument = + serde_json::from_str(include_str!("../themes/top-bar.json")).unwrap(); + theme.surfaces[0].placement.offset_y = 20; + theme.surfaces[0].placement.auto_hide = false; + theme.prepare_runtime(); + let authored = theme.surfaces[0].placement.clone(); + assert!(authored.edge().is_some() && authored.auto_hide_edge().is_none()); + let mut saved = placement("floating"); + saved.screen_x = 400; + saved.screen_y = 300; + let state = state_for(theme, saved); + assert_ne!( + effective_theme_from_state(&state).unwrap().surfaces[0].placement, + authored + ); +} + +#[test] +fn the_low_usage_alarm_sounds_once_holds_the_top_bar_open_and_can_be_snoozed_or_turned_off() { + use crate::models::{UsageData, UsageSection}; + let mut top_bar: ThemeDocument = + serde_json::from_str(include_str!("../themes/top-bar.json")).unwrap(); + top_bar.prepare_runtime(); + let usage = |percentage| UsageData { + session: UsageSection { + available: true, + percentage, + resets_at: None, + }, + ..Default::default() + }; + let data = |readings: &[(ProviderId, f64)]| { + Some(AppUsageData::from_iter(readings.iter().map( + |(provider, percentage)| (*provider, usage(*percentage)), + ))) + }; + // What the render path does: update the alarm, then ask the theme. + let forced = |state: &AppState| { + let theme = effective_theme_from_state(state).unwrap(); + theme_engine::surface_force_reveal( + &theme, + 0, + state.data.as_ref(), + theme_runtime_from_state(state), + ) + }; + let mut state = state_for(top_bar, placement("floating")); + state.providers = ProviderSet::from_enabled([ProviderId::Claude, ProviderId::Codex]); + + state.data = data(&[(ProviderId::Claude, 94.0)]); + assert!( + !update_low_usage_alarm(&mut state), + "6% left is not an alarm" + ); + assert!(!forced(&state)); + state.data = data(&[(ProviderId::Claude, 95.0)]); + assert!(update_low_usage_alarm(&mut state), "5% left sounds"); + assert!(forced(&state)); + for _ in 0..3 { + assert!( + !update_low_usage_alarm(&mut state), + "later polls stay quiet" + ); + assert!(forced(&state)); + } + + // Snoozed, the bar may hide while that limit stays low. + state.alarm.snooze(Instant::now()); + assert!(!update_low_usage_alarm(&mut state)); + assert!(!forced(&state)); + // Another limit crossing during the snooze alarms again. + state.data = data(&[(ProviderId::Claude, 95.0), (ProviderId::Codex, 97.0)]); + let later = std::time::SystemTime::now() + Duration::from_secs(120); + assert!(update_low_usage_alarm_at( + &mut state, + later, + mono_for(later) + )); + assert!(forced(&state)); + + // Providers publishing one by one within a poll: the second crossing 3 s + // after the first still holds the bar open but plays no second sound. + state.data = data(&[(ProviderId::Claude, 10.0), (ProviderId::Codex, 10.0)]); + update_low_usage_alarm_at(&mut state, later, mono_for(later)); + state.data = data(&[(ProviderId::Claude, 96.0), (ProviderId::Codex, 10.0)]); + let t = later + Duration::from_secs(70); + assert!( + update_low_usage_alarm_at(&mut state, t, mono_for(t)), + "first result" + ); + state.data = data(&[(ProviderId::Claude, 96.0), (ProviderId::Codex, 97.0)]); + let t3 = t + Duration::from_secs(3); + assert!(!update_low_usage_alarm_at(&mut state, t3, mono_for(t3))); + assert!(forced(&state)); + state.data = data(&[(ProviderId::Claude, 96.0), (ProviderId::Codex, 10.0)]); + let t4 = t + Duration::from_secs(4); + update_low_usage_alarm_at(&mut state, t4, mono_for(t4)); + state.data = data(&[(ProviderId::Claude, 96.0), (ProviderId::Codex, 97.0)]); + let t120 = t + Duration::from_secs(120); + assert!(update_low_usage_alarm_at(&mut state, t120, mono_for(t120))); + state.data = data(&[(ProviderId::Claude, 95.0), (ProviderId::Codex, 97.0)]); + + // No data for a moment (a poll gap) is not a recovery: still held open, + // and the same low readings returning stay silent. + state.data = None; + assert!(!update_low_usage_alarm(&mut state)); + assert!(forced(&state)); + state.data = data(&[(ProviderId::Claude, 95.0), (ProviderId::Codex, 97.0)]); + assert!(!update_low_usage_alarm(&mut state)); + assert!(forced(&state)); + + // After the resets it hides normally again. + state.data = data(&[(ProviderId::Claude, 3.0), (ProviderId::Codex, 0.0)]); + assert!(!update_low_usage_alarm(&mut state)); + assert!(!forced(&state)); + + // Turned off: no sound and never held open. + state.low_usage_alarm = false; + state.data = data(&[(ProviderId::Claude, 99.0)]); + assert!(!update_low_usage_alarm(&mut state)); + assert!(!forced(&state)); +} + +/// A usage cache from a report of a silent alarm: Codex weekly at 96% used +/// (4% left) with no five-hour window, Claude far from its limits. Times are +/// placeholders `@N@`, seconds after now, filled in by `sample_cache` so the +/// fixture never ages past the clock the alarm reads. +const SAMPLE_CACHE: &str = r#"{ + "updated_unix": @0@, + "poll_ok": true, + "data": { + "claude_code": { + "session": {"available": true, "percentage": 55.0, "resets_at": {"secs_since_epoch": @5400@, "nanos_since_epoch": 0}}, + "weekly": {"available": true, "percentage": 10.0, "resets_at": {"secs_since_epoch": @280800@, "nanos_since_epoch": 0}}, + "limits": [ + {"key": "iguana_necktie", "kind": "iguana_necktie", "label": "iguana necktie", "model": null, "model_id": null, "scope": null, "is_active": false, "usage": {"available": true, "percentage": 0.0, "resets_at": {"secs_since_epoch": @2257000@, "nanos_since_epoch": 0}}}, + {"key": "session", "kind": "session", "label": "session", "model": null, "model_id": null, "scope": null, "is_active": true, "usage": {"available": true, "percentage": 55.0, "resets_at": {"secs_since_epoch": @5400@, "nanos_since_epoch": 0}}}, + {"key": "weekly_all", "kind": "weekly_all", "label": "weekly all", "model": null, "model_id": null, "scope": null, "is_active": false, "usage": {"available": true, "percentage": 10.0, "resets_at": {"secs_since_epoch": @280800@, "nanos_since_epoch": 0}}}, + {"key": "weekly_scoped_fable", "kind": "weekly_scoped", "label": "Fable", "model": "Fable", "model_id": null, "scope": {"model": {"display_name": "Fable", "id": null}, "surface": null}, "is_active": false, "usage": {"available": true, "percentage": 0.0, "resets_at": {"secs_since_epoch": @280801@, "nanos_since_epoch": 0}}} + ] + }, + "codex": { + "session": {"available": false, "percentage": 0.0, "resets_at": null}, + "weekly": {"available": true, "percentage": 96.0, "resets_at": {"secs_since_epoch": @343000@, "nanos_since_epoch": 0}} + }, + "accounts": [ + { + "provider": "claude", + "profile": {"id": "default", "name": "Default", "config_dir": "", "credentials_path": "", "enabled": true}, + "source_signature": "", + "usage": { + "session": {"available": true, "percentage": 55.0, "resets_at": {"secs_since_epoch": @5400@, "nanos_since_epoch": 0}}, + "weekly": {"available": true, "percentage": 10.0, "resets_at": {"secs_since_epoch": @280800@, "nanos_since_epoch": 0}}, + "limits": [ + {"key": "iguana_necktie", "kind": "iguana_necktie", "label": "iguana necktie", "model": null, "model_id": null, "scope": null, "is_active": false, "usage": {"available": true, "percentage": 0.0, "resets_at": {"secs_since_epoch": @2257000@, "nanos_since_epoch": 0}}}, + {"key": "session", "kind": "session", "label": "session", "model": null, "model_id": null, "scope": null, "is_active": true, "usage": {"available": true, "percentage": 55.0, "resets_at": {"secs_since_epoch": @5400@, "nanos_since_epoch": 0}}}, + {"key": "weekly_all", "kind": "weekly_all", "label": "weekly all", "model": null, "model_id": null, "scope": null, "is_active": false, "usage": {"available": true, "percentage": 10.0, "resets_at": {"secs_since_epoch": @280800@, "nanos_since_epoch": 0}}}, + {"key": "weekly_scoped_fable", "kind": "weekly_scoped", "label": "Fable", "model": "Fable", "model_id": null, "scope": {"model": {"display_name": "Fable", "id": null}, "surface": null}, "is_active": false, "usage": {"available": true, "percentage": 0.0, "resets_at": {"secs_since_epoch": @280801@, "nanos_since_epoch": 0}}} + ] + }, + "error": null, + "selected": true + }, + { + "provider": "codex", + "profile": {"id": "default", "name": "Default", "config_dir": "", "credentials_path": "", "enabled": true}, + "source_signature": "", + "usage": { + "session": {"available": false, "percentage": 0.0, "resets_at": null}, + "weekly": {"available": true, "percentage": 96.0, "resets_at": {"secs_since_epoch": @343000@, "nanos_since_epoch": 0}} + }, + "error": null, + "selected": true + } + ] + } +}"#; + +/// `SAMPLE_CACHE` with every time placed relative to `now`. +fn sample_cache(now: u64) -> String { + let mut cache = SAMPLE_CACHE.to_string(); + for offset in [0, 5400, 280800, 280801, 2257000, 343000] { + cache = cache.replace(&format!("@{offset}@"), &(now + offset).to_string()); + } + cache +} + +#[test] +fn the_sample_cache_has_no_absolute_times_left() { + let cache = sample_cache(2_000_000_000); + assert!(!cache.contains('@'), "every placeholder is filled"); + assert!(cache.contains("2000005400"), "times follow the given clock"); + assert!( + !cache.contains("1791"), + "no absolute epoch from a real cache" + ); +} + +/// Settings for it: Claude and Codex on, counting what is used, no alarm setting +/// saved (so it is on), and a floating placement left by another theme. +const SAMPLE_SETTINGS: &str = r#"{ + "accounts": { + "claude": {"profiles": [{"config_dir": "", "credentials_path": "", "enabled": true, "id": "default", "name": "Default"}], "selected": "default", "used_ids": ["default"]}, + "codex": {"profiles": [{"config_dir": "", "credentials_path": "", "enabled": true, "id": "default", "name": "Default"}], "selected": "default", "used_ids": ["default"]} + }, + "custom_theme_enabled": true, + "placement_override": {"floating_host": {"height": 48, "surface_id": "main", "theme_id": "compact-fluent-quad", "width": 1920}, "monitor_index": 1, "nest": "floating", "screen_x": 3529, "screen_y": 126, "tray_offset": 0}, + "poll_interval_ms": 60000, + "show_antigravity": false, + "show_claude_code": true, + "show_codex": true, + "show_cursor": false, + "show_grok": false, + "show_opencode": false, + "usage_countdown": false +}"#; + +/// Tests driving the app's global state and the auto-hide presenter run one at +/// a time. +static LIVE_APP: Mutex<()> = Mutex::new(()); + +unsafe extern "system" fn plain_window( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + DefWindowProcW(hwnd, message, wparam, lparam) +} + +/// The app started with the sample settings and the Top Bar from the theme +/// library, before its first poll. Everything runs for real down to Windows: +/// the alarm is counted instead of played, and the window is a private one +/// that is only marked shown (see `auto_hide::show`). +struct LiveApp { + window: HWND, + _one_at_a_time: MutexGuard<'static, ()>, +} + +impl LiveApp { + fn start() -> Self { + let guard = LIVE_APP.lock().unwrap_or_else(|error| error.into_inner()); + std::fs::write(app_settings::settings_path(), SAMPLE_SETTINGS).unwrap(); + let settings = load_settings(); + theme_engine::ensure_starter_theme().unwrap(); + let theme = + theme_engine::load_theme(&theme_engine::themes_directory().join("top-bar.json")) + .unwrap(); + let window = unsafe { + let class = native_interop::wide_str("UsageMonitorAlarmCallSite"); + let instance = GetModuleHandleW(PCWSTR::null()).unwrap(); + RegisterClassW(&WNDCLASSW { + lpfnWndProc: Some(plain_window), + hInstance: instance.into(), + lpszClassName: PCWSTR(class.as_ptr()), + ..Default::default() + }); + CreateWindowExW( + WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + PCWSTR(class.as_ptr()), + PCWSTR::null(), + WS_POPUP, + 0, + 0, + 1, + 1, + None, + None, + Some(instance.into()), + None, + ) + .unwrap() + }; + let mut state = state_for(theme, settings.placement_override.clone().unwrap()); + state.hwnd = SendHwnd::from_hwnd(window); + state.providers = settings.enabled_providers(); + state.accounts = settings.accounts.clone(); + state.low_usage_alarm = settings.low_usage_alarm; + state.usage_countdown = settings.usage_countdown; + state.is_dark = crate::theme::is_dark_mode(); + *lock_state() = Some(state); + let app = Self { + window, + _one_at_a_time: guard, + }; + render_layered(); + app + } + + /// A poll delivers the sample reading, and the widget redraws as it does + /// on WM_APP_USAGE_UPDATED. + fn poll_result(&self) { + let cache: app_settings::UsageCache = + serde_json::from_str(&sample_cache(now_unix_secs())).unwrap(); + if let Some(state) = lock_state().as_mut() { + state.data = Some(cache.data); + state.last_poll_ok = true; + state.last_update_unix = Some(now_unix_secs()); + state.last_fresh_unix = Some(now_unix_secs()); + } + render_layered(); + } + + /// Let the hide-until-hover timer run, as the message loop would. + fn run_timer(&self, duration: Duration) { + let end = Instant::now() + duration; + while Instant::now() < end { + unsafe { + wnd_proc(self.window, WM_TIMER, WPARAM(TIMER_AUTO_HIDE), LPARAM(0)); + } + std::thread::sleep(Duration::from_millis(15)); + } + } + + fn sounds() -> usize { + low_usage_alarm::SOUNDED.with(std::cell::Cell::get) + } + + /// The window's size on screen, and whether it lets clicks through. + fn presented(&self) -> ((i32, i32), bool) { + let rect = native_interop::get_window_rect_safe(self.window).unwrap(); + let ex_style = unsafe { GetWindowLongW(self.window, GWL_EXSTYLE) } as u32; + ( + (rect.right - rect.left, rect.bottom - rect.top), + ex_style & WS_EX_TRANSPARENT.0 != 0, + ) + } + + /// The open bar's and the collapsed tab's sizes in device pixels. + fn sizes(&self) -> ((i32, i32), (i32, i32)) { + let state = lock_state(); + let state = state.as_ref().unwrap(); + let theme = effective_theme_from_state(state).unwrap(); + let scale = theme_surface_scale(&theme, 0); + let runtime = theme_runtime_for_surface(&theme, 0, theme_runtime_from_state(state)); + let (width, height) = + theme_engine::resolve_surface_size(&theme, 0, state.data.as_ref(), runtime); + let handle = theme_engine::render_surface_handle( + &theme, + 0, + state.data.as_ref(), + runtime, + scale, + theme_engine::SurfaceEdge::Top, + ); + ( + ( + scaled_theme_dimension(width, scale), + scaled_theme_dimension(height, scale), + ), + (handle.width as i32, handle.height as i32), + ) + } + + /// Where a layer's centre is, in the window's device pixels. + fn centre_of(&self, id: &str) -> Option { + let state = lock_state(); + let state = state.as_ref().unwrap(); + let theme = effective_theme_from_state(state).unwrap(); + let scale = theme_surface_scale(&theme, 0); + let runtime = theme_runtime_for_surface(&theme, 0, theme_runtime_from_state(state)); + let index = theme.surfaces[0] + .children + .iter() + .position(|layer| layer.id == id) + .unwrap(); + let (x, y, width, height) = theme_engine::resolve_object_bounds_with_runtime( + &theme, + 0, + index, + state.data.as_ref(), + runtime, + )?; + let x = ((x + width / 2.0) * scale).round() as u32; + let y = ((y + height / 2.0) * scale).round() as u32; + Some(LPARAM(((y << 16) | x) as isize)) + } + + fn click(&self, at: LPARAM) { + unsafe { + wnd_proc(self.window, WM_LBUTTONDOWN, WPARAM(1), at); + wnd_proc(self.window, WM_LBUTTONUP, WPARAM(0), at); + } + } +} + +impl Drop for LiveApp { + fn drop(&mut self) { + *lock_state() = None; + auto_hide::retain(&[]); + auto_hide::show(self.window, false); + unsafe { + let _ = DestroyWindow(self.window); + } + } +} + +#[test] +fn a_surface_on_a_display_that_is_gone_lands_on_the_first_display() { + let mut top_bar: ThemeDocument = + serde_json::from_str(include_str!("../themes/top-bar.json")).unwrap(); + top_bar.prepare_runtime(); + let mut rect_on = |display: usize| { + top_bar.placement.reference.display = display; + let (rect, _) = positioning::surface_target(&top_bar, 1.0).unwrap(); + (rect.left, rect.top, rect.right, rect.bottom) + }; + let first = rect_on(0); + assert_eq!(rect_on(native_interop::find_monitors().len() + 2), first); +} + +#[test] +fn the_pointer_poll_sleeps_while_every_auto_hiding_surface_is_hidden_and_wakes_when_one_shows() { + let app = LiveApp::start(); + app.run_timer(Duration::from_millis(400)); + assert_eq!( + auto_hide::poll_interval(), + Some(auto_hide::POLL_INTERVAL_MS) + ); + assert!(auto_hide::take_timer(app.window), "running while collapsed"); + auto_hide::schedule(app.window); + + // A fullscreen app hides the bar: the poll stops once it is collapsed. + auto_hide::show(app.window, false); + app.run_timer(Duration::from_millis(100)); + assert_eq!(auto_hide::poll_interval(), None, "hidden and collapsed"); + assert!(!auto_hide::take_timer(app.window), "no timer while hidden"); + + // The visibility timer shows it again and the poll comes back with it. + sync_theme_window_visibility(); + assert_eq!( + auto_hide::poll_interval(), + Some(auto_hide::POLL_INTERVAL_MS) + ); + assert!(auto_hide::take_timer(app.window), "timer restarted"); + auto_hide::schedule(app.window); + + // Hidden while still open: one more tick collapses it, then the poll stops. + app.poll_result(); + app.run_timer(Duration::from_millis(500)); + auto_hide::show(app.window, false); + assert!(auto_hide::poll_interval().is_some(), "still open"); + app.run_timer(Duration::from_millis(100)); + assert_eq!(auto_hide::poll_interval(), None); + assert!(!auto_hide::take_timer(app.window)); +} + +#[test] +fn the_sample_reading_sounds_once_and_the_hide_timer_holds_the_top_bar_open() { + let app = LiveApp::start(); + let (_, collapsed) = app.sizes(); + app.run_timer(Duration::from_millis(400)); + assert_eq!(app.presented(), (collapsed, true), "before any usage"); + assert_eq!(LiveApp::sounds(), 0); + + app.poll_result(); + assert_eq!(LiveApp::sounds(), 1, "Codex weekly has 4% left"); + let (open, collapsed) = app.sizes(); + assert!(open.1 > 10 * collapsed.1, "{open:?} {collapsed:?}"); + app.run_timer(Duration::from_millis(500)); + assert_eq!(app.presented(), (open, false), "held open and clickable"); + + // Later polls with the same reading stay quiet and keep it open, well + // past the moment it would slide away after a hover. + for _ in 0..2 { + app.poll_result(); + app.run_timer(auto_hide::LEAVE_DELAY + auto_hide::HIDE_DURATION); + } + assert_eq!(LiveApp::sounds(), 1); + assert_eq!(app.presented(), (open, false)); +} + +#[test] +fn several_quotas_crossing_in_one_poll_play_one_sound() { + use crate::models::{UsageData, UsageSection}; + let app = LiveApp::start(); + assert_eq!(LiveApp::sounds(), 0); + let low = || UsageData { + session: UsageSection { + available: true, + percentage: 97.0, + resets_at: None, + }, + weekly: UsageSection { + available: true, + percentage: 98.0, + resets_at: None, + }, + ..Default::default() + }; + if let Some(state) = lock_state().as_mut() { + state.data = Some(AppUsageData::from_iter([ + (ProviderId::Claude, low()), + (ProviderId::Codex, low()), + ])); + state.last_poll_ok = true; + } + render_layered(); + assert_eq!(LiveApp::sounds(), 1, "four quotas crossed, one sound"); + render_layered(); + assert_eq!(LiveApp::sounds(), 1); + drop(app); +} + +#[test] +fn the_snooze_button_on_the_held_bar_lets_it_hide_exactly_like_the_menu_item() { + for use_menu in [false, true] { + let app = LiveApp::start(); + app.poll_result(); + app.run_timer(Duration::from_millis(500)); + let (open, collapsed) = app.sizes(); + assert_eq!(app.presented(), (open, false), "menu={use_menu}"); + let button = app + .centre_of("snooze-button") + .expect("the held bar shows the Snooze button"); + if use_menu { + window_context_menu::execute_context_menu_action( + app.window, + ContextMenuAction::SnoozeAlarm, + None, + ); + } else { + app.click(button); + } + let remaining = lock_state() + .as_ref() + .and_then(|state| state.alarm.snooze_remaining(Instant::now())) + .expect("snoozed"); + assert!( + remaining > low_usage_alarm::SNOOZE - Duration::from_secs(60), + "{remaining:?}" + ); + assert!( + app.centre_of("snooze-button").is_none(), + "hidden once snoozed" + ); + app.run_timer(auto_hide::LEAVE_DELAY + auto_hide::HIDE_DURATION * 2); + assert_eq!(app.presented(), (collapsed, true), "menu={use_menu}"); + // The same reading arriving again neither sounds nor reopens it. + app.poll_result(); + app.run_timer(Duration::from_millis(300)); + assert_eq!(app.presented(), (collapsed, true), "menu={use_menu}"); + assert_eq!(LiveApp::sounds(), 1 + use_menu as usize); + } +} diff --git a/src/window/placement_tests.rs b/src/window/placement_tests.rs index d227e6ed..99227fbf 100644 --- a/src/window/placement_tests.rs +++ b/src/window/placement_tests.rs @@ -877,3 +877,302 @@ fn can_redock_at_tray(free_space: i32, width: i32) -> bool { }; occupancy.can_restore(widget, 20) } + +fn top_bar_theme() -> ThemeDocument { + let mut theme: ThemeDocument = + serde_json::from_str(include_str!("../themes/top-bar.json")).unwrap(); + theme.prepare_runtime(); + theme +} + +// A two-monitor layout: DISPLAY1 primary at the origin, DISPLAY2 to its right. +const DISPLAYS: [RECT; 2] = [ + RECT { + left: 0, + top: 0, + right: 1920, + bottom: 1080, + }, + RECT { + left: 1920, + top: 0, + right: 3840, + bottom: 1080, + }, +]; + +#[test] +fn top_bar_docks_at_the_top_centre_of_each_monitor_and_dpi() { + let theme = top_bar_theme(); + let placement = &theme.surfaces[0].placement; + assert_eq!( + placement.auto_hide_edge(), + Some(theme_engine::SurfaceEdge::Top) + ); + for monitor in DISPLAYS { + for scale in [1.0, 1.25, 1.5] { + let runtime = ThemeRuntime::from_providers(ProviderSet::from_enabled([ + ProviderId::Claude, + ProviderId::Codex, + ProviderId::Cursor, + ])) + .with_nest(SurfaceNest::Floating) + .with_host_dimensions( + logical_host_dimension(monitor.right - monitor.left, scale), + logical_host_dimension(monitor.bottom - monitor.top, scale), + ); + let frame = positioning::widget_frame(&theme, None, runtime, scale); + assert_eq!(frame.inset, 0); + assert_eq!(frame.height, scaled_theme_dimension(84, scale)); + let rect = positioning::surface_screen_rect( + placement, + frame.width, + frame.height, + scale, + monitor, + None, + None, + ); + let context = format!("monitor={monitor:?} scale={scale}"); + assert_eq!(rect.top, monitor.top, "{context}"); + assert_eq!(rect.bottom - rect.top, frame.height, "{context}"); + assert!( + (rect.left + rect.right - monitor.left - monitor.right).abs() <= 1, + "{context}: {rect:?}" + ); + assert!( + rect.left >= monitor.left && rect.right <= monitor.right, + "{context}" + ); + + let handle = ( + scaled_theme_dimension(64, scale), + scaled_theme_dimension(6, scale), + ); + let layout = auto_hide::layout( + rect, + theme_engine::SurfaceEdge::Top, + (handle.0 as u32, handle.1 as u32), + scale, + ); + let centre = (monitor.left + monitor.right) / 2; + for (target, width, depth) in [ + (layout.handle, handle.0, handle.1), + ( + layout.zone, + scaled_theme_dimension(200, scale), + scaled_theme_dimension(6, scale), + ), + ] { + assert_eq!(target.top, monitor.top, "{context}"); + assert_eq!(target.bottom - target.top, depth, "{context}"); + assert_eq!(target.right - target.left, width, "{context}"); + assert!( + ((target.left + target.right) / 2 - centre).abs() <= 1, + "{context}" + ); + } + } + } +} + +#[test] +fn hidden_surfaces_show_a_handle_and_slide_out_from_their_edge() { + let surface = RECT { + left: 2400, + top: 0, + right: 3400, + bottom: 138, + }; + let top = auto_hide::layout(surface, theme_engine::SurfaceEdge::Top, (96, 9), 1.5); + let collapsed = auto_hide::frame(&top, 0.0); + assert!(collapsed.handle && !collapsed.interactive); + assert_eq!(collapsed.window, top.handle); + assert_eq!( + ( + top.handle.left, + top.handle.top, + top.handle.right, + top.handle.bottom + ), + (2852, 0, 2948, 9) + ); + + let half = auto_hide::frame(&top, 0.5); + assert!(!half.handle && !half.interactive); + // The bottom half of the bar shows first, hanging from the screen edge. + assert_eq!((half.window.top, half.window.bottom), (0, 69)); + assert_eq!((half.window.left, half.window.right), (2400, 3400)); + assert_eq!(half.source_top, 69); + assert!(half.opacity > 89 && half.opacity < 255); + + let open = auto_hide::frame(&top, 1.0); + assert!(!open.handle && open.interactive); + assert_eq!(open.window, surface); + assert_eq!((open.source_top, open.opacity), (0, 255)); + + let bottom_surface = RECT { + top: 942, + bottom: 1080, + ..surface + }; + let bottom = auto_hide::layout( + bottom_surface, + theme_engine::SurfaceEdge::Bottom, + (96, 9), + 1.5, + ); + assert_eq!((bottom.handle.top, bottom.handle.bottom), (1071, 1080)); + assert_eq!((bottom.zone.top, bottom.zone.bottom), (1071, 1080)); + let rising = auto_hide::frame(&bottom, 0.5); + assert_eq!((rising.window.top, rising.window.bottom), (1011, 1080)); + assert_eq!(rising.source_top, 0); + + // Leaving the open bar is measured from its full rectangle plus slack. + let pointer = |x, y| auto_hide::pointer(&top, POINT { x, y }, false, 1.5); + assert!(pointer(2900, 3).over_handle); + assert!(!pointer(2700, 3).over_handle); + assert!(pointer(2700, 140).over_surface); + assert!(!pointer(2700, 150).over_surface); +} + +#[test] +fn auto_hiding_bar_only_takes_input_when_fully_open() { + let layout = auto_hide::layout(DISPLAYS[0], theme_engine::SurfaceEdge::Top, (64, 6), 1.0); + for eased in [0.0, 0.01, 0.5, 0.99] { + assert!(!auto_hide::frame(&layout, eased).interactive, "{eased}"); + } + assert!(auto_hide::frame(&layout, 1.0).interactive); +} + +#[test] +fn reveal_needs_a_resting_pointer_ignores_drags_and_lingers_after_leaving() { + use auto_hide::{Pointer, Reveal, HIDE_DURATION, INTENT_DELAY, LEAVE_DELAY, REVEAL_DURATION}; + let start = Instant::now(); + let at = |ms: u64| start + Duration::from_millis(ms); + let handle = Pointer { + over_handle: true, + ..Default::default() + }; + let surface = Pointer { + over_surface: true, + ..Default::default() + }; + let away = Pointer::default(); + let dragging = Pointer { + button_down: true, + ..handle + }; + assert_eq!(INTENT_DELAY, Duration::from_millis(80)); + assert_eq!(LEAVE_DELAY, Duration::from_millis(600)); + + // A pointer sweeping past the handle does not open it. + let mut reveal = Reveal::default(); + reveal.tick(at(0), handle); + reveal.tick(at(40), handle); + reveal.tick(at(56), away); + reveal.tick(at(120), handle); + reveal.tick(at(160), handle); + assert!(!reveal.open, "the dwell must restart after leaving"); + + // Dragging a window up to the edge never opens it. + let mut held = Reveal::default(); + for ms in (0..1_000).step_by(40) { + held.tick(at(ms), dragging); + } + assert!(!held.open && held.progress == 0.0); + + // Resting opens after the intent delay and slides in over ~250 ms. + let mut ms = 160; + while !reveal.open { + ms += 16; + reveal.tick(at(ms), handle); + } + assert!((200..=216).contains(&ms), "opened at {ms} ms"); + let opened_at = ms; + let mut previous = reveal.eased(); + let mut frames = 0; + while reveal.busy() { + ms += 16; + reveal.tick(at(ms), surface); + assert!(reveal.eased() >= previous); + previous = reveal.eased(); + frames += 1; + } + let slide = (ms - opened_at) as u128; + assert!( + (REVEAL_DURATION.as_millis()..=REVEAL_DURATION.as_millis() + 16).contains(&slide), + "slide took {slide} ms" + ); + assert!(frames >= 15, "{frames} frames is below 60 fps"); + assert_eq!(reveal.eased(), 1.0); + + // It stays open while the pointer is over it, then lingers after it leaves. + let left_at = ms + 1_000; + reveal.tick(at(left_at - 500), surface); + reveal.tick(at(left_at), away); + reveal.tick(at(left_at + 590), away); + assert!(reveal.open, "closed before the leave delay"); + reveal.tick(at(left_at + 600), surface); + reveal.tick(at(left_at + 700), away); + reveal.tick(at(left_at + 1_250), away); + assert!(reveal.open, "returning must restart the leave delay"); + reveal.tick(at(left_at + 1_300), away); + assert!(!reveal.open); + let closed_at = left_at + 1_300; + let mut ms = 0; + while reveal.progress > 0.0 { + ms += 16; + reveal.tick(at(closed_at + ms), away); + } + assert!( + ms as u128 <= HIDE_DURATION.as_millis() + 16, + "hid in {ms} ms" + ); + assert!(!reveal.busy()); +} + +#[test] +fn a_forced_reveal_opens_at_once_stays_open_and_then_hides_normally() { + use auto_hide::{Pointer, Reveal, LEAVE_DELAY, REVEAL_DURATION}; + let start = Instant::now(); + let at = |ms: u64| start + Duration::from_millis(ms); + let away = Pointer::default(); + let forced = Pointer { + force_reveal: true, + ..away + }; + let dragging = Pointer { + button_down: true, + ..forced + }; + + // No pointer anywhere near it, not even the handle: it slides down. + let mut reveal = Reveal::default(); + reveal.tick(at(0), forced); + assert!(reveal.open, "a forced surface opens on the first tick"); + let mut ms = 0; + while reveal.busy() { + ms += 16; + reveal.tick(at(ms), dragging); + } + assert!(ms as u128 <= REVEAL_DURATION.as_millis() + 16); + assert_eq!(reveal.eased(), 1.0); + + // It stays down for hours with the pointer away. + for minute in 1..=180 { + reveal.tick(at(ms + minute * 60_000), forced); + assert!(reveal.open, "hid {minute} min into the alarm"); + } + + // Once nothing forces it, the usual leave delay applies and it hides. + let cleared = ms + 181 * 60_000; + reveal.tick(at(cleared), away); + reveal.tick(at(cleared + LEAVE_DELAY.as_millis() as u64 - 10), away); + assert!(reveal.open); + reveal.tick(at(cleared + LEAVE_DELAY.as_millis() as u64), away); + assert!( + !reveal.open, + "the surface hides again once the alarm clears" + ); +} diff --git a/src/window/positioning.rs b/src/window/positioning.rs index 56d5cac6..2df478fb 100644 --- a/src/window/positioning.rs +++ b/src/window/positioning.rs @@ -166,9 +166,42 @@ pub(super) fn render_custom_window( render_desktop_custom_window(hwnd, rendered); return; } + present_layered( + hwnd, + rendered, + LayeredFrame { + source_top: 0, + height: rendered.height as i32, + origin: None, + opacity: 255, + interactive: true, + }, + ); +} + +/// Which rows of a rendered surface a layered window shows, and where. +pub(super) struct LayeredFrame { + pub source_top: i32, + pub height: i32, + /// Screen position, or `None` to keep the window where it is. + pub origin: Option, + pub opacity: u8, + /// Keep fully transparent pixels hit-testable. + pub interactive: bool, +} +pub(super) fn present_layered( + hwnd: HWND, + rendered: &theme_engine::RenderedTheme, + frame: LayeredFrame, +) { let width = rendered.width as i32; - let height = rendered.height as i32; + let source_top = frame.source_top.clamp(0, rendered.height as i32); + let height = frame + .height + .clamp(1, (rendered.height as i32 - source_top).max(1)); + let rows = (rendered.width as usize * source_top as usize) + ..(rendered.width as usize * (source_top + height) as usize).min(rendered.pixels.len()); unsafe { // Keep the DWM surface alive across frames. Desktop rendering uses a // separate DirectComposition window, so no layered-style reset is @@ -200,12 +233,13 @@ pub(super) fn render_custom_window( return; } let old = SelectObject(memory_dc, bitmap.into()); - let window_pixels = std::slice::from_raw_parts_mut(bits as *mut u32, rendered.pixels.len()); - for (target, source) in window_pixels.iter_mut().zip(&rendered.pixels) { + let source_pixels = &rendered.pixels[rows]; + let window_pixels = std::slice::from_raw_parts_mut(bits as *mut u32, source_pixels.len()); + for (target, source) in window_pixels.iter_mut().zip(source_pixels) { // Windows normally lets mouse input pass through zero-alpha pixels in // layered windows. A nearly transparent pixel keeps the full surface // interactive without changing the theme renderer's pixel output. - *target = if source >> 24 == 0 { + *target = if frame.interactive && source >> 24 == 0 { 0x0100_0000 } else { *source @@ -219,13 +253,13 @@ pub(super) fn render_custom_window( let blend = BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, - SourceConstantAlpha: 255, + SourceConstantAlpha: frame.opacity, AlphaFormat: AC_SRC_ALPHA as u8, }; if let Err(error) = UpdateLayeredWindow( hwnd, Some(screen_dc), - None, + frame.origin.as_ref().map(|origin| origin as *const POINT), Some(&size), Some(memory_dc), Some(&source), @@ -254,36 +288,14 @@ pub(super) fn position_custom_theme_internal(hwnd: HWND, theme: &ThemeDocument, let state = lock_state(); state.as_ref().is_some_and(|s| s.dragging) }; - if is_dragging { + // Auto-hiding surfaces are positioned by their presenter, frame by frame. + if is_dragging || theme.placement.auto_hide_edge().is_some() { return; } - let taskbars = native_interop::find_taskbars(); - let displays = native_interop::find_monitors(); - let display_index = theme.placement.reference.display; - let selected_display = displays - .get(display_index) - .copied() - .or_else(|| displays.first().copied()); - let Some(display) = selected_display else { + let Some((rect, taskbar)) = surface_target(theme, scale) else { return; }; - let taskbar = taskbars.iter().find(|taskbar| unsafe { - MonitorFromWindow(taskbar.hwnd, MONITOR_DEFAULTTOPRIMARY) == display.handle - }); - let width = scaled_theme_dimension(theme.canvas.width.max(1), scale); - let height = scaled_theme_dimension(theme.canvas.height.max(1), scale); - let tray = taskbar - .and_then(|tb| native_interop::find_child_window(tb.hwnd, "TrayNotifyWnd")) - .and_then(native_interop::get_window_rect_safe); - let rect = surface_screen_rect( - &theme.placement, - width, - height, - scale, - display.rect, - taskbar.map(|tb| tb.rect), - tray, - ); + let (width, height) = (rect.right - rect.left, rect.bottom - rect.top); let (x, y) = (rect.left, rect.top); let nest = theme .placement @@ -351,6 +363,40 @@ pub(super) fn position_custom_theme_internal(hwnd: HWND, theme: &ThemeDocument, } } +/// Screen rectangle of a positioned surface on its selected display, and the +/// taskbar that display owns. +pub(super) fn surface_target( + theme: &ThemeDocument, + scale: f64, +) -> Option<(RECT, Option)> { + let taskbars = native_interop::find_taskbars(); + let displays = native_interop::find_monitors(); + let display = displays + .get(native_interop::display_or_first( + theme.placement.reference.display, + displays.len(), + )) + .copied()?; + let taskbar = taskbars.into_iter().find(|taskbar| unsafe { + MonitorFromWindow(taskbar.hwnd, MONITOR_DEFAULTTOPRIMARY) == display.handle + }); + let width = scaled_theme_dimension(theme.canvas.width.max(1), scale); + let height = scaled_theme_dimension(theme.canvas.height.max(1), scale); + let tray = taskbar + .and_then(|tb| native_interop::find_child_window(tb.hwnd, "TrayNotifyWnd")) + .and_then(native_interop::get_window_rect_safe); + let rect = surface_screen_rect( + &theme.placement, + width, + height, + scale, + display.rect, + taskbar.map(|tb| tb.rect), + tray, + ); + Some((rect, taskbar)) +} + pub(super) fn sync_theme_window_visibility() { let (theme, data, runtime, windows) = { let state = lock_state(); @@ -372,6 +418,7 @@ pub(super) fn sync_theme_window_visibility() { .collect::>(), ) }; + let mut resume_poll = false; unsafe { for (surface_index, surface) in theme.surfaces.iter().enumerate() { let nest = surface @@ -396,7 +443,16 @@ pub(super) fn sync_theme_window_visibility() { data.as_ref(), surface_runtime, ) && !foreground_is_fullscreen_on_display(surface.placement.reference.display); - if should_show == IsWindowVisible(hwnd).as_bool() { + // Auto-hiding surfaces are shown and hidden by their own module, + // which also restarts the pointer poll it suspends while they are + // hidden. + let auto_hiding = auto_hide::owns(hwnd); + let visible = if auto_hiding { + auto_hide::is_shown(hwnd) + } else { + IsWindowVisible(hwnd).as_bool() + }; + if should_show == visible { continue; } if should_show { @@ -410,19 +466,31 @@ pub(super) fn sync_theme_window_visibility() { SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, ); } - let _ = ShowWindow( - hwnd, - if should_show { - SW_SHOWNOACTIVATE - } else { - SW_HIDE - }, - ); + if auto_hiding { + auto_hide::show(hwnd, should_show); + resume_poll = true; + } else { + let _ = ShowWindow( + hwnd, + if should_show { + SW_SHOWNOACTIVATE + } else { + SW_HIDE + }, + ); + } } } + if resume_poll { + auto_hide::schedule(windows[0].to_hwnd()); + } } pub(super) fn foreground_is_fullscreen_on_display(display_index: usize) -> bool { + // Tests must not depend on whatever app the developer has in front. + if cfg!(test) { + return false; + } unsafe { let foreground = GetForegroundWindow(); if foreground.is_invalid() diff --git a/src/window/taskbar_collision.rs b/src/window/taskbar_collision.rs index cb84370c..bde2ef22 100644 --- a/src/window/taskbar_collision.rs +++ b/src/window/taskbar_collision.rs @@ -501,7 +501,7 @@ mod tests { #[test] fn stale_failed_moved_and_recreated_taskbars_are_unknown_not_free() { let now = Instant::now(); - let hwnd = HWND(1usize as *mut _); + let hwnd = HWND(std::ptr::dangling_mut()); let other = HWND(2usize as *mut _); let bounds = rect(0, 1032, 1920, 1080); let sample = layout(bounds, vec![rect(700, 1032, 1220, 1080)]); diff --git a/src/window/window_context_menu.rs b/src/window/window_context_menu.rs index cc784541..22bdd1b2 100644 --- a/src/window/window_context_menu.rs +++ b/src/window/window_context_menu.rs @@ -33,6 +33,8 @@ pub(super) fn show_context_menu_document( let mut point = POINT::default(); let _ = GetCursorPos(&mut point); let _ = SetForegroundWindow(hwnd); + // The menu sits outside an auto-hiding surface; keep that surface open. + auto_hide::hold_open(true); let selected = TrackPopupMenu( menu, TPM_RIGHTBUTTON | TPM_RETURNCMD, @@ -43,6 +45,7 @@ pub(super) fn show_context_menu_document( None, ) .0 as usize; + auto_hide::hold_open(false); let _ = DestroyMenu(menu); if selected >= 1_000 { if let Some(action) = actions.get(selected - 1_000).cloned() { @@ -282,7 +285,8 @@ pub(super) fn execute_context_menu_action( | ContextMenuAction::LegacyResetPosition | ContextMenuAction::ToggleLayerRender { .. } | ContextMenuAction::LayerActions { .. } - | ContextMenuAction::OpenUrl { .. } => None, + | ContextMenuAction::OpenUrl { .. } + | ContextMenuAction::SnoozeAlarm => None, }; if let Some(command) = static_command { unsafe { @@ -318,6 +322,7 @@ pub(super) fn execute_context_menu_action( ContextMenuAction::OpenUrl { url } => { open_web_url(hwnd, &url, "context menu URL could not be opened") } + ContextMenuAction::SnoozeAlarm => snooze_low_usage_alarm(hwnd), _ => {} } } diff --git a/tools/gen_top_bar.py b/tools/gen_top_bar.py new file mode 100644 index 00000000..3313f142 --- /dev/null +++ b/tools/gen_top_bar.py @@ -0,0 +1,526 @@ +"""Generate src/themes/top-bar.json (Windows only: text widths come from GDI). + +Run from the repository root: python tools/gen_top_bar.py +It rewrites src/themes/top-bar.json, reading the shipped languages from +src/localization/locales/*.toml. Needs Python 3.11+ and the Segoe UI Variable +font. Commit the regenerated JSON together with any change to this file. + +Look: a flat web-app design language. Flat surfaces, a 1px +ring plus a 1px drop for depth, grey labels, loud numbers, and status colours +on tinted pills. Every colour is a light/dark token pair chosen by `system.dark`. +""" +import glob +import json +import math +import os +import tomllib +import ctypes +from ctypes import wintypes + +gdi = ctypes.windll.gdi32 +user = ctypes.windll.user32 +gdi.CreateFontW.restype = wintypes.HFONT +gdi.CreateCompatibleDC.restype = wintypes.HDC +gdi.CreateCompatibleDC.argtypes = [wintypes.HDC] +gdi.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ] +gdi.SelectObject.restype = wintypes.HGDIOBJ +gdi.DeleteObject.argtypes = [wintypes.HGDIOBJ] +gdi.DeleteDC.argtypes = [wintypes.HDC] +gdi.GetTextExtentPoint32W.argtypes = [wintypes.HDC, wintypes.LPCWSTR, ctypes.c_int, ctypes.c_void_p] +class SIZE(ctypes.Structure): + _fields_ = [("cx", ctypes.c_long), ("cy", ctypes.c_long)] +def measure(text, size, weight, family="Segoe UI Variable Text"): + dc = gdi.CreateCompatibleDC(None) + font = gdi.CreateFontW(-size, 0, 0, 0, weight, 0, 0, 0, 1, 7, 0, 4, 0, family) + old = gdi.SelectObject(dc, font) + s = SIZE() + gdi.GetTextExtentPoint32W(dc, text, len(text), ctypes.byref(s)) + gdi.SelectObject(dc, old); gdi.DeleteObject(font); gdi.DeleteDC(dc) + return s.cx + + +FONT = "Segoe UI Variable Text" +SM, PAD, R = 4, 16, 16 # shadow margin, card side padding, card corner radius +TOP, HEAD, CH, BOTTOM = 6, 20, 80, 8 +# The status pill sits in the header row over the last CLI's cells, so the +# cells start a clear gap below it rather than touching its bottom edge. +STATUS_H, ROW_GAP = 18, 5 +CELLS_Y = TOP + (HEAD + STATUS_H) // 2 + ROW_GAP +CELL, CGAP, BGAP = 112, 16, 40 +UNIT = CELL + CGAP + +# (light, dark) colour tokens. +SURFACE = ("#FFFFFF", "#19191C") +RING = ("#ECECEC", "#FFFFFF14") +DROP = ("#28282814", "#00000066") +LINE = ("#EBEBEB", "#2A2A2F") +TEXT = ("#171717", "#EDEDEF") +TEXT2 = ("#5C5C5C", "#B4B4BA") +TEXT3 = ("#6B6B6B", "#9B9BA2") +FAINT = ("#A3A3A3", "#6B6B73") +TB = ("#F0F0F0", "#242428") +IDLE = ("#C7C7C7", "#55555C") +GREEN, GREEN_TINT, GREEN_TEXT = ("#18C669", "#2FD27F"), ("#E4FCEF", "#2FD27F1F"), ("#0F7A4A", "#4ADE94") +AMBER, AMBER_TINT, AMBER_TEXT = ("#FFA100", "#FFB020"), ("#FFF4E0", "#FFB0201F"), ("#8A5600", "#FFC35C") +RED, RED_TINT, RED_TEXT = ("#EF4444", "#F26B6B"), ("#FEF0EF", "#F26B6B1F"), ("#B42318", "#FF8F8F") +WHITE = ("#FFFFFF", "#FFFFFF") + + +def tok(pair): + light, dark = pair + return light if light == dark else f'if(system.dark, "{dark}", "{light}")' + + +def quoted(pair): + """A token as an expression operand (bare colours must be quoted).""" + expression = tok(pair) + return expression if expression.startswith("if(") else f'"{expression}"' + + +def by_level(pct, alarm, normal, amber, red, alarming): + """Colour by usage level: 70%+ amber, 90%+ red, almost spent the alarm colour.""" + return (f"if({alarm}, {quoted(alarming)}, if({pct} >= 90, {quoted(red)}, " + f"if({pct} >= 70, {quoted(amber)}, {quoted(normal)})))") + + +# GDI widths at 96 DPI (measure below): names are 12px semibold, status 12px regular. +NAME_W = {"claude": 42, "codex": 38, "antigravity": 69, "copilot": 45, "cursor": 40, "grok": 29, "opencode": 64} +PILL_DOT, PILL_TEXT, PILL_RIGHT = 8, 18, 6 # status pill: dot inset, text inset, right padding + + +def slack(width): + """Room for hinting: at 125-200% GDI draws this text up to 4.1% wider than at 96 DPI.""" + return f"(({width}) * 1.045 + 1)" + + +# Status text and countdown suffixes are localized (src/localization/locales), +# so the widths above, measured for English, grow per language. EN_* are the +# English widths the layout is drawn for; each shipped language's measured +# width beyond them is added through `lang_extra`, rounded up to LANG_STEP px. +LANG_STEP = 4 +EN_SUFFIX = {"day": 7, "hour": 7, "minute": 10} # "d" "h" "m" +EN_STATUS = {"waiting": 119, "live": 21, "stale": 25, "live_age": 100, "stale_age": 104} +STATUS_KEYS = {"waiting": "status_waiting", "live": "status_live", "stale": "status_stale", + "live_age": "status_live_updated", "stale_age": "status_stale_updated"} + + +def load_locales(): + found = {} + for path in glob.glob(os.path.join(os.path.dirname(__file__), "..", "src", "localization", "locales", "*.toml")): + with open(path, "rb") as f: + data = tomllib.load(f) + found[data["code"]] = data["strings"] + return found + + +LOCALES = load_locales() +assert len(LOCALES) == 14, sorted(LOCALES) + + +def text_w(value): + return measure(value, 12, 400) + + +def suffix_w(strings): + return {unit: text_w(strings[f"{unit}_suffix"]) for unit in EN_SUFFIX} + + +def static_w(strings, state): + return text_w(strings[STATUS_KEYS[state]].replace("{time}", "")) + + +def countdown_extra(strings): + """Widest the countdown grows beyond English: the ranges "<1m", "Nm", "Nh Nm" and "Nd Nh".""" + w = {unit: width - EN_SUFFIX[unit] for unit, width in suffix_w(strings).items()} + return max(w["minute"], w["hour"] + w["minute"], w["day"] + w["hour"]) + + +def status_extra(strings, state): + """Pixels the status text of this state grows beyond English in this language.""" + grow = static_w(strings, state) - static_w(LOCALES["en"], state) + grow += countdown_extra(strings) if state.endswith("_age") else 0 + return max(0, math.ceil(grow / LANG_STEP) * LANG_STEP) + + +def lang_pick(by_language): + """An expression choosing a number by `i18n.locale`; the commonest value is the fallback.""" + values = sorted(set(by_language.values())) + groups = {v: sorted(c for c, x in by_language.items() if x == v) for v in values} + fallback = max(values, key=lambda v: (len(groups[v]), -v)) + if len(values) == 1: + return str(fallback) + expression = str(fallback) + for v in sorted((v for v in values if v != fallback), reverse=True): + test = " || ".join(f'i18n.locale == "{c}"' for c in groups[v]) + expression = f"if({test}, {v}, {expression})" + return expression + + +LANG_EXTRA = {state: lang_pick({c: status_extra(strings, state) for c, strings in LOCALES.items()}) + for state in EN_STATUS} +# The last block holds the widest status of any state. +LANG_RESERVE = lang_pick({c: math.ceil(max(status_extra(strings, state) for state in EN_STATUS) * 1.045) + for c, strings in LOCALES.items()}) +# Cell countdowns grow by the language's widest suffix combination. +LANG_COUNTDOWN = lang_pick({c: max(0, math.ceil(countdown_extra(strings) / LANG_STEP) * LANG_STEP) + for c, strings in LOCALES.items()}) + +# Whole pixels, so a bar at its natural width never rounds into shrinking. +STATUS_RESERVE = PILL_TEXT + math.ceil(148 * 1.045 + 1) + PILL_RIGHT # "Stale · updated 23h 59m ago" +FABLE_W = 31 # "Fable " +VALUE_PAD = 7 # level pill padding either side of the percentage +LABEL_GAP = 6 # least room between a label and its value +RESETS_W = 45 # "Resets in" at 11px +SCOPE_W = 36 # "weekly" at 12px +# The alarm's "Snooze 1h" button, left of the status pill: a bell-off glyph +# (Segoe MDL2 Assets, on Windows 10 and 11) and 12px semibold text (GDI 62). +SNOOZE_ICON, SNOOZE_ICON_W = "", 12 +SNOOZE_PAD, SNOOZE_SPACE, SNOOZE_GAP = 7, 4, 6 +SNOOZE_W = SNOOZE_PAD + SNOOZE_ICON_W + SNOOZE_SPACE + math.ceil(62 * 1.045 + 1) + SNOOZE_PAD + +WEEKLY = '{if(%s.weekly.label == "30d", "Monthly", if(%s.weekly.label == "API", "API", "Weekly"))}' + + +def windows(p): + """(id, binding base, label template, render condition) per limit, left to right.""" + if p == "claude": + return [("five_hour", "claude.five_hour", "5-hour", "1"), + ("weekly", "claude.weekly", "Weekly", "1"), + ("fable", "claude.model.fable", "Fable", "claude.model.fable.available")] + if p == "codex": + return [("five_hour", "codex.five_hour", "5-hour", "1"), + ("weekly", "codex.weekly", WEEKLY % (p, p), "1")] + session = "Auto" if p == "cursor" else "5-hour" + result = [("five_hour", f"{p}.five_hour", session, f"{p}.five_hour.available"), + ("weekly", f"{p}.weekly", WEEKLY % (p, p), "1")] + if p == "opencode": + result.append(("monthly", "opencode.monthly", "Monthly", "opencode.monthly.available")) + return result + + +# Identity dots, toned to sit with the status colours in both modes. +PROVIDERS = [("claude", "Claude", "#D97757"), ("codex", "Codex", "#5B5FD6"), + ("antigravity", "Antigravity", "#14A3A0"), ("copilot", "Copilot", "#8B5CF6"), + ("cursor", "Cursor", "#E0569B"), ("grok", "Grok", "#8A8A93"), + ("opencode", "OpenCode", "#6AA81A")] +KEYS = [p for p, _, _ in PROVIDERS] + + +def en(p): + return f"providers.{p}.enabled" + + +def av(p): + return f"{p}.available" + + +def cells(p): + conds = [c for _, _, _, c in windows(p)] + fixed = sum(1 for c in conds if c == "1") + return " + ".join([str(fixed)] + [c for c in conds if c != "1"]) + + +def earlier(p): + """Some CLI to the left is enabled, so this block follows a divider.""" + before = KEYS[:KEYS.index(p)] + return f"({' || '.join(en(q) for q in before)})" if before else "0" + + +def last(p): + later = KEYS[KEYS.index(p) + 1:] + return f"!({' || '.join(en(q) for q in later)})" if later else "1" + + +def na_width(p): + return 17 + NAME_W[p] + 6 + + +def reserve(p): + """The last block also holds the status pill, and the Snooze button beside it during an alarm.""" + return f"({17 + NAME_W[p] + 16 + STATUS_RESERVE} + {LANG_RESERVE} + alarm.active * {SNOOZE_W + SNOOZE_GAP})" + + +def span(p): + return f"(({cells(p)}) * {UNIT} - {CGAP})" + + +DIVIDERS = "(providers.count - 1)" +NATURAL = f"{2 * (SM + PAD)} + {DIVIDERS} * {BGAP + 1} + " + " + ".join( + f"{en(p)} * max({av(p)} * {span(p)} + (1 - {av(p)}) * {na_width(p)}, {last(p)} * {reserve(p)})" + for p in KEYS) +# Cells and spacing shrink by one factor k when every enabled CLI no longer +# fits. Width is B + k*V + max(0, R_last - content_last(k)); solve both +# branches of the max and take the smaller k (both are increasing in k). +BASE = f"{2 * (SM + PAD)} + {DIVIDERS} + " + " + ".join( + f"{en(p)} * (1 - {av(p)}) * {na_width(p)}" for p in KEYS) +VARIABLE = f"{DIVIDERS} * {BGAP} + " + " + ".join(f"{en(p)} * {av(p)} * {span(p)}" for p in KEYS) +RESERVE = " + ".join(f"{en(p)} * {last(p)} * ({reserve(p)} - (1 - {av(p)}) * {na_width(p)})" for p in KEYS) +LAST_VARIABLE = " + ".join(f"{en(p)} * {last(p)} * {av(p)} * {span(p)}" for p in KEYS) +SHRINK = (f"clamp(min((canvas.width - ({BASE})) / max(1, {VARIABLE}), " + f"if(({VARIABLE}) - ({LAST_VARIABLE}) > 0, (canvas.width - ({BASE}) - ({RESERVE})) / " + f"max(1, ({VARIABLE}) - ({LAST_VARIABLE})), 1)), 0.2, 1)") +# The provider row's gap is half the scaled block spacing (a divider sits in +# the middle), so every block reads the shrink factor as parent.gap / 20. +K = f"parent.gap / {BGAP // 2}" + + +STALE = "data.has_error || " + " || ".join(f"({en(p)} && {p}.stale)" for p in KEYS) +SHOWN = [f"{en(p)} * {base}.available * {base}.percentage" for p in KEYS for _, base, _, _ in windows(p)] +WORST = SHOWN[-1] +for term in reversed(SHOWN[:-1]): + WORST = f"max({term}, {WORST})" +HANDLE = (f"if(data.loading || {STALE}, {quoted(IDLE)}, if({WORST} >= 90, {quoted(RED)}, " + f"if({WORST} >= 70, {quoted(AMBER)}, {quoted(TEXT3)})))") + + +def digits(v): + """Pixel width of a one- or two-digit number at 12px ("1" is narrower).""" + return f"(({v}) >= 10) * if(floor(({v}) / 10) == 1, 5, 6) + if(({v}) % 10 == 1, 5, 6)" + + +def percent_width(v): + """Pixel width of "%" at 13px semibold, up to three digits ("1" is narrower).""" + return (f"(({v}) >= 100) * if(floor(({v}) / 100) == 1, 6, 8) + " + f"(({v}) >= 10) * if(floor(({v}) / 10) % 10 == 1, 6, 8) + if(({v}) % 10 == 1, 6, 8) + 12") + + +def countdown_width(s, d=EN_SUFFIX["day"], h=EN_SUFFIX["hour"], m=EN_SUFFIX["minute"]): + """Pixel width of ":countdown" at 12px regular: "<1" 13, a space 3, and the suffixes d, h, m + (English 7, 7, 10).""" + return (f"if({s} < 60, {13 + m}, if({s} < 3600, {digits(f'floor({s} / 60)')} + {m}, " + f"if({s} < 86400, {digits(f'floor({s} / 3600)')} + {digits(f'floor({s} % 3600 / 60)')} + {h + m + 3}, " + f"{digits(f'floor({s} / 86400)')} + {digits(f'floor({s} % 86400 / 3600)')} + {d + h + 3})))") + + +# The status pill is drawn for English and grows per language (LANG_EXTRA). +COUNTDOWN_W = countdown_width("data.updated.seconds") +# Cells fit "Resets in" beside the countdown in the user's language. +CELL_COUNTDOWN = lambda s: f"{countdown_width(s)} + {LANG_COUNTDOWN}" + + +def obj(id, name, parent=None, render="1", x="0", y="0", w="100", h="20", **extra): + """A layer with every engine default omitted, to keep the embedded theme small.""" + o = {"id": id, "name": name} + if render != "1": + o["render"] = render + visibility = extra.pop("visibility", "100") + if visibility != "100": + o["visibility"] = visibility + if parent: + o["parent"] = parent + if "anchor" in extra: + o["anchor"] = extra.pop("anchor") + for key, value in [("x", x), ("y", y)]: + if str(value) != "0": + o[key] = str(value) + o["width"], o["height"] = str(w), str(h) + for key, default in [("background", None), ("border", None), ("mouse_events", None), ("radius", "0"), + ("layout", "freeform"), ("gap", "0"), ("content", None)]: + value = extra.pop(key, default) + if value is not None and str(value) != str(default): + o["corner_radius" if key == "radius" else key] = ( + value if key in ("background", "border", "mouse_events", "content") else str(value)) + assert not extra, extra + return o + + +def paint(c, opacity="1"): + """A colour literal, a (light, dark) token pair, or a colour expression.""" + return {"color": tok(c) if isinstance(c, tuple) else c, "opacity": str(opacity)} + + +def colour(c, opacity="1"): + return {"type": "colour", "colour": paint(c, opacity)} + + +def text(template, size, weight, c, align="left", opacity="1", family=None): + o = {"type": "text", "template": template, "font_size": str(size), "contrast": "1.2"} + if family: + o["font_family"] = family + if weight != "regular": + o["weight"] = weight + if align != "left": + o["align"] = align + o["color"] = paint(c, opacity) + return o + + +layers = [] +# Depth is a 1px drop under a 1px ring (`0 1px 2px`, `0 0 0 1px`), no soft shadow. +for i, opacity in [(2, "0.5"), (1, "1")]: + layers.append(obj(f"drop-{i}", f"Drop {i}", x=SM - i + 1, y=-R, w=f"canvas.width - {2 * (SM - i + 1)}", + h=CH + R + i, background=colour(DROP, opacity), radius=R + i - 1)) +layers.append(obj("card", "Card", x=SM, y=-R, w=f"canvas.width - {2 * SM}", h=CH + R, + background=colour(SURFACE), radius=R, border={"color": paint(RING), "width": "1"})) +layers.append(obj("providers", "Enabled CLIs", x=SM + PAD, w=f"canvas.width - {2 * (SM + PAD)}", + h=CH, layout="row", gap=f"{BGAP // 2} * {SHRINK}")) + +for p, name, accent in PROVIDERS: + if earlier(p) != "0": + layers.append(obj(f"{p}-divider", f"{name} divider", "providers", f"{en(p)} && {earlier(p)}", + y=TOP + 3, w=1, h=CH - TOP - BOTTOM - 3, background=colour(LINE))) + box = f"{p}-block" + layers.append(obj(box, f"{name} block", "providers", en(p), gap="parent.gap", + w=f"max({av(p)} * {K} * {span(p)} + (1 - {av(p)}) * {na_width(p)}, " + f"{last(p)} * {reserve(p)})", h=CH)) + # A 7px dot in a 3px soft ring, like a project tab. + layers.append(obj(f"{p}-halo", f"{name} halo", box, x=0, y=TOP + HEAD / 2 - 6.5, w=13, h=13, + background=colour(accent, 0.18), radius=6.5)) + layers.append(obj(f"{p}-dot", f"{name} dot", box, x=3, y=TOP + HEAD / 2 - 3.5, w=7, h=7, + background=colour(accent), radius=3.5)) + layers.append(obj(f"{p}-name", f"{name} name", box, x=17, y=TOP, w="parent.width - 17", h=HEAD, + content=text(name, 12, "semibold", TEXT))) + layers.append(obj(f"{p}-na", f"{name} unavailable", box, f"!{av(p)}", y=CELLS_Y, w="parent.width", + h=18, content=text("n/a", 13, "semibold", FAINT))) + row = f"{p}-cells" + n = cells(p) + layers.append(obj(row, f"{name} limits", box, av(p), y=CELLS_Y, w=f"{K} * {span(p)}", + h=CH - CELLS_Y - BOTTOM, layout="row", + gap=f"parent.width * {CGAP} / max(1, ({n}) * {UNIT} - {CGAP})")) + for key, base, label, cond in windows(p): + cell = f"{p}-{key}" + a, pct, shown, alarm = f"{base}.available", f"{base}.percentage", f"{base}.display", f"{base}.alarm" + layers.append(obj(cell, f"{name} {key}", row, cond, + w=f"(parent.width - (({n}) - 1) * parent.gap) / max(1, {n})", h=42)) + # 70%+ and 90%+ sit on a tinted pill; an almost spent limit on solid red. + flagged = f"{a} && ({pct} >= 70 || {alarm})" + shown_w = percent_width(f"max(0, round({shown}))") + pill_w = f"{shown_w} + {2 * VALUE_PAD}" + # Labels end before the value or its pill ("n/a" is 22), so a squeezed + # cell ellipsizes the label instead of running it into the number. + label_room = f"parent.width - if({a}, {shown_w} + if({pct} >= 70 || {alarm}, {2 * VALUE_PAD}, 0), 22) - {LABEL_GAP}" + label_colour = f"if({alarm}, {quoted(RED_TEXT)}, if({a}, {quoted(TEXT3)}, {quoted(FAINT)}))" + # Under 10px nothing readable survives, only a sliver of an ellipsis. + layers.append(obj(f"{cell}-label", f"{name} {key} label", cell, w=label_room, h=18, + visibility=f"if({label_room} >= 10, 100, 0)", + content=text(label, 12, "regular", label_colour))) + if key == "fable": + # "weekly" shows only where all of it fits before the value. + layers.append(obj(f"{cell}-label-scope", f"{name} {key} scope", cell, x=FABLE_W, + w=f"{label_room} - {FABLE_W}", h=18, + visibility=f"if({label_room} >= {FABLE_W} + {slack(SCOPE_W)}, 100, 0)", + content=text("weekly", 12, "regular", FAINT))) + layers.append(obj(f"{cell}-pill", f"{name} {key} level pill", cell, flagged, + x=f"parent.width - ({pill_w})", w=pill_w, h=18, radius=8, + background=colour(by_level(pct, alarm, TB, AMBER_TINT, RED_TINT, RED)))) + layers.append(obj(f"{cell}-value", f"{name} {key} value", cell, a, + w=f"parent.width - if({pct} >= 70 || {alarm}, {VALUE_PAD}, 0)", h=18, + content=text(f"{{{shown}:0}}%", 13, "semibold", + by_level(pct, alarm, TEXT, AMBER_TEXT, RED_TEXT, WHITE), "right"))) + layers.append(obj(f"{cell}-na", f"{name} {key} unavailable", cell, f"!{a}", w="parent.width", h=18, + content=text("n/a", 13, "semibold", FAINT, "right"))) + layers.append(obj(f"{cell}-track", f"{name} {key} track", cell, y=21, w="parent.width", h=4, + background=colour(LINE, f"if({a}, 1, 0.5)"), radius=2)) + layers.append(obj(f"{cell}-fill", f"{name} {key} fill", cell, f"{a} && {shown} > 0", y=21, + w=f"max(4, parent.width * min({shown}, 100) / 100)", h=4, radius=2, + background=colour(by_level(pct, alarm, TEXT, AMBER, RED, RED)))) + resets = f"{a} && {base}.reset.unix > 0" + # "Resets in" gives way to the countdown when a squeezed cell cannot hold both. + fits = f"{slack(RESETS_W)} + {LABEL_GAP} + {slack(CELL_COUNTDOWN(f'{base}.reset.seconds'))}" + layers.append(obj(f"{cell}-resets", f"{name} {key} resets in", cell, resets, y=28, + w="parent.width", h=14, visibility=f"if(parent.width >= {fits}, 100, 0)", + content=text("Resets in", 11, "regular", TEXT3))) + layers.append(obj(f"{cell}-countdown", f"{name} {key} countdown", cell, resets, y=28, + w="parent.width", h=14, + content=text(f"{{{base}.reset.seconds:countdown}}", 12, "regular", TEXT, "right"))) + +# Live / stale status as a small status pill, right-aligned in the header row +# above the last CLI. The block reserves its width beside the name, and the +# cells start ROW_GAP below it, so it never meets a name, label or level pill. +right = SM + PAD +status_y = TOP + (HEAD - STATUS_H) // 2 +SHOWN_STATUS = {} +live = f"!data.loading && !({STALE})" +stale = f"!data.loading && ({STALE})" +for id, name, render, state, template, width, tint, ink, dot in [ + ("status-waiting", "Waiting", "data.loading", "waiting", "{i18n.status_waiting}", + str(EN_STATUS["waiting"]), TB, TEXT2, IDLE), + ("status-live", "Live", f"{live} && data.updated.available", "live_age", "{i18n.status_live_updated}", + f"{EN_STATUS['live_age']} + {COUNTDOWN_W}", GREEN_TINT, GREEN_TEXT, GREEN), + ("status-live-untimed", "Live (no time)", f"{live} && !data.updated.available", "live", + "{i18n.status_live}", str(EN_STATUS["live"]), GREEN_TINT, GREEN_TEXT, GREEN), + ("status-stale", "Stale", f"{stale} && data.updated.available", "stale_age", + "{i18n.status_stale_updated}", f"{EN_STATUS['stale_age']} + {COUNTDOWN_W}", AMBER_TINT, AMBER_TEXT, AMBER), + ("status-stale-untimed", "Stale (no time)", f"{stale} && !data.updated.available", "stale", + "{i18n.status_stale}", str(EN_STATUS["stale"]), AMBER_TINT, AMBER_TEXT, AMBER), +]: + # Wider where this language's words and suffixes are (LANG_EXTRA). + room = slack(f"{width} + {LANG_EXTRA[state]}") + SHOWN_STATUS[state] = room + # The dot and the text are the pill's children, so the language-sized width + # is written once. The text is centred in room measured with slack, so text + # drawn wider at fractional scales than at 96 DPI never runs into the dot + # or the pill's end. + layers.append(obj(f"{id}-pill", f"{name} pill", None, render, + x=f"canvas.width - {right} - {room} - {PILL_TEXT + PILL_RIGHT}", y=status_y, + w=f"{room} + {PILL_TEXT + PILL_RIGHT}", h=STATUS_H, radius=8, background=colour(tint))) + layers.append(obj(f"{id}-dot", f"{name} dot", f"{id}-pill", x=PILL_DOT, y=TOP + HEAD / 2 - 3 - status_y, + w=6, h=6, background=colour(dot), radius=3)) + layers.append(obj(id, f"{name} status", f"{id}-pill", x=PILL_TEXT, w=f"parent.width - {PILL_TEXT + PILL_RIGHT}", + h=STATUS_H, content=text(template, 12, "regular", ink, "center"))) + +# While the alarm holds the bar down, a "Snooze 1h" button left of whichever +# status pill shows does what the menu's "Snooze alarm (1 hour)" does. The +# last block reserves its width, so it never meets that CLI's name. +shown_status = (f"if(data.loading, {SHOWN_STATUS['waiting']}, if(({STALE}), if(data.updated.available, " + f"{SHOWN_STATUS['stale_age']}, {SHOWN_STATUS['stale']}), if(data.updated.available, " + f"{SHOWN_STATUS['live_age']}, {SHOWN_STATUS['live']})))") +snooze_x = f"canvas.width - {right} - ({shown_status}) - {PILL_TEXT + PILL_RIGHT} - {SNOOZE_GAP} - {SNOOZE_W}" +layers.append(obj("snooze-button", "Snooze button", None, "alarm.active", x=snooze_x, y=status_y, + w=SNOOZE_W, h=STATUS_H, radius=8, background=colour(RED_TINT), + border={"color": paint(RED, 0.45), "width": "1"}, + mouse_events={"click": "snooze_alarm()"})) +layers.append(obj("snooze-button-icon", "Snooze button icon", None, "alarm.active", + x=f"{snooze_x} + {SNOOZE_PAD}", y=status_y, w=SNOOZE_ICON_W, h=STATUS_H, + content=text(SNOOZE_ICON, 11, "regular", RED_TEXT, "center", family="Segoe MDL2 Assets"))) +layers.append(obj("snooze-button-text", "Snooze button text", None, "alarm.active", + x=f"{snooze_x} + {SNOOZE_PAD + SNOOZE_ICON_W + SNOOZE_SPACE}", y=status_y, + w=f"{SNOOZE_W - 2 * SNOOZE_PAD - SNOOZE_ICON_W - SNOOZE_SPACE}", h=STATUS_H, + content=text("Snooze 1h", 12, "semibold", RED_TEXT, "center"))) + +theme = { + "schema_version": 1, + "id": "top-bar", + "name": "Top Bar", + "surfaces": [{ + "id": "main", + "name": "Top bar", + "render": "1", + "visibility": "100", + "placement": { + "reference": {"region": "monitor", "display": 0}, + "nest": "floating", + "horizontal": "center", + "vertical": "top", + "surface_horizontal": "center", + "surface_vertical": "top", + "offset_x": 0, + "offset_y": 0, + "auto_hide": True, + "handle_color": HANDLE, + "handle_background": tok(SURFACE), + # An almost spent limit keeps the bar down until it resets or is snoozed. + "force_reveal": "alarm.active", + }, + "width": f"min({NATURAL}, max(160, host.width - 16))", + "height": str(CH + SM), + # Transparent rather than none: the bar draws its own card, so + # floating hosts must not add their automatic card behind it. + "background": colour("#00000000"), + "border": None, + "mouse_events": {"double_click": "show_dashboard()", + "right_click": "show_context_menu(\"classic-v1\")"}, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": {"type": "none"}, + "children": layers, + }], +} + +out = os.path.join(os.path.dirname(__file__), "..", "src", "themes", "top-bar.json") +with open(out, "w", encoding="utf-8", newline="\n") as f: + json.dump(theme, f, indent=2, ensure_ascii=False) + f.write("\n") +print(len(layers), "layers,", os.path.getsize(out), "bytes")