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 00000000..eace9993 Binary files /dev/null and b/docs/images/top-bar-alarm.png differ diff --git a/docs/images/top-bar-dark.png b/docs/images/top-bar-dark.png new file mode 100644 index 00000000..770e4d97 Binary files /dev/null and b/docs/images/top-bar-dark.png differ diff --git a/docs/images/top-bar-light.png b/docs/images/top-bar-light.png new file mode 100644 index 00000000..e03daddf Binary files /dev/null and b/docs/images/top-bar-light.png differ 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 00000000..c2e7d92f Binary files /dev/null and b/src/assets/alarm-warning-pulse.wav differ diff --git a/src/context_menu.rs b/src/context_menu.rs index 6743bf0a..4cc1901b 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -84,6 +84,8 @@ pub enum ContextMenuAction { OpenUrl { url: String, }, + /// Let edge-docked widgets hide again for an hour during a low-usage alarm. + SnoozeAlarm, Exit, } @@ -323,6 +325,7 @@ pub fn rendered_label( "Check for updates" => language.text("Check for updates"), "Lock in taskbar" => language.text("Lock in taskbar"), "Show widget" => language.text("Show widget"), + "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")