Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ test_bin/
# Local dev builds and scratch
/builds/
/scratch/

# Approval screenshots rendered from fixture data
/approval/
101 changes: 99 additions & 2 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<slug>` 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**.
Expand Down
1 change: 1 addition & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Binary file added docs/images/top-bar-alarm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/top-bar-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/top-bar-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 122 additions & 2 deletions src/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ pub struct SettingsFile {
pub lock_taskbar: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement_override: Option<PlacementOverride>,
/// 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<bool>,
/// Display index for edge-docked widgets; `None` keeps the theme's choice.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edge_display: Option<usize>,
/// 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)]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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<Option<u64>>,
pub poll_ok: bool,
pub data: AppUsageData,
}

fn present_or_null<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Option<u64>>, D::Error> {
Option::<u64>::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<u64> {
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")
Expand Down Expand Up @@ -372,11 +408,19 @@ pub fn load_usage_cache() -> Option<UsageCache> {
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<u64>,
) -> 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(),
},
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/assets/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added src/assets/alarm-warning-pulse.wav
Binary file not shown.
35 changes: 35 additions & 0 deletions src/context_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading