Compare commits

..
4 Commits
Author SHA1 Message Date
tiennm99 677feb2e42 chore: bump version to 0.7.0 2026-10-27 13:09:01 +07:00
tiennm99 5362e46be5 fix: bring hidden or covered bubbles back every minute
Another always-on-top window, or a shell action that hid ours, could
leave a bubble covered or hidden until restart. The fullscreen check
thread now shows every ready bubble again and puts it back at the top of
the always-on-top band once a minute, unless the user or a fullscreen
app has them hidden. Windows re-asserts HWND_TOPMOST without activating;
other platforms drop and re-set always-on-top.
2026-10-27 12:11:24 +07:00
tiennm99 97100cfcc0 feat: keyhole-shaped see-through bubble with a click-through option
The bubble is now a full-height round head with a slimmer, centred tail
whose end has small rounded corners, clipped in the page so only the
shape takes input. Its background is 60% opaque; text, rings and bars
stay solid. A new Click-through menu item makes the bubbles ignore the
mouse so clicks reach the windows below; it is saved as click_through
and turned off again from the tray menu.
2026-10-27 12:11:24 +07:00
tiennm99 4299c8e602 feat: drop the OpenCode Go provider placeholder
OpenCode Go never had a usage source, so the provider list, its colours,
label, window lengths, default position and settings fields go. With it
goes the live/non-live split it introduced: every provider is now live,
and toggling still keeps at least one enabled. Saved settings that carry
the old OpenCode Go keys still load; the keys are dropped on next save.
2026-10-27 12:02:44 +07:00
23 changed files with 277 additions and 233 deletions

No files matched your search

Generated
+1 -1
View File
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]]
name = "usage-monitor"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"auto-launch",
"dbus",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "usage-monitor"
version = "0.6.1"
version = "0.7.0"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage"
+13 -7
View File
@@ -27,8 +27,9 @@ self-updater are all written from scratch against the same public APIs
## What you get
- A circular floating bubble showing your current 5-hour Claude Code or
Codex usage as a percentage and a colored progress ring
- A see-through floating bubble: a round head showing your current 5-hour
Claude Code or Codex usage as a percentage and a colored progress ring,
with a slimmer tail holding the 7-day usage and reset bars
- Drag anywhere — the bubble snaps to monitor work-area edges when
released
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
@@ -37,12 +38,15 @@ self-updater are all written from scratch against the same public APIs
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed providers, update frequency, startup,
**Reset position**, updates, **Show widget**, **Restart** and **Exit**
**Reset position**, updates, **Show widget**, **Click-through**,
**Restart** and **Exit**
- A system tray icon per enabled provider, with a usage badge
- Desktop notifications when usage crosses a threshold, when a sign-in
expires and after an update was applied
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
- Every minute, shown bubbles are put back on top, so another
always-on-top window can't keep them covered or hidden
## Who this is for
@@ -166,7 +170,8 @@ to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for **Refresh**, **Update frequency**, **Providers**,
**Settings**, **Controls**, **Show widget**, **Restart** and **Exit**.
**Settings**, **Controls**, **Show widget**, **Click-through**,
**Restart** and **Exit**.
**Settings** holds "Start with Windows" ("Start at login" on Linux),
**Reset position**, a manual **Check for updates** and the
**Auto-update check** interval (Disabled / Hourly / Daily / Weekly)
@@ -175,6 +180,9 @@ to the nearest edge if you let go close to one.
menu, to resize it
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
the same menu (on Linux, any click opens the menu)
- **Click-through:** when checked, the bubbles ignore the mouse, so clicks
reach the windows underneath. You can't drag, resize or right-click a
bubble then; uncheck it from the tray icon's menu
### Providers
@@ -182,9 +190,6 @@ Use the right-click **Providers** menu to choose what is shown:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
- **OpenCode Go** is listed for future support; usage bars remain disabled
until OpenCode exposes a stable quota source or console integration is
explicitly enabled
When multiple providers are shown, each gets its own bubble that you can position
independently.
@@ -271,6 +276,7 @@ What the app stores locally:
- Bubble position(s) per model
- Bubble size
- Whether the bubbles are shown (**Show widget**)
- Whether the bubbles let clicks through (**Click-through**)
- Polling frequency
- Auto-update check interval
- Last update check time
+1 -3
View File
@@ -126,8 +126,6 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
}
}
@@ -178,7 +176,7 @@ mod tests {
#[test]
fn arc_starts_at_twelve_o_clock_for_any_percent() {
for kind in ProviderId::LIVE_USAGE {
for kind in ProviderId::ALL {
for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] {
let pixmap = render_pixmap(kind, Some(pct));
assert_eq!(
+64 -3
View File
@@ -263,7 +263,7 @@ fn set_size(window: &WebviewWindow, (w, h): (i32, i32)) {
/// Bubble windows that exist, with their provider, in display order.
fn windows(app: &AppHandle) -> Vec<(ProviderId, WebviewWindow)> {
ProviderId::LIVE_USAGE
ProviderId::ALL
.into_iter()
.filter_map(|p| {
app.get_webview_window(&runtime::bubble_label(p))
@@ -316,10 +316,11 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
log::warn!("{label} not created: the runtime is not ready");
return Ok(());
};
let (size_logical, saved) = runtime.with_state(|s| {
let (size_logical, saved, click_through) = runtime.with_state(|s| {
(
s.settings.bubble_size_logical,
s.settings.bubble_positions.get(provider),
s.settings.click_through,
)
});
let all = screens(app);
@@ -350,6 +351,9 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
set_position(&window, placement.pos);
}
set_size(&window, placement.size);
if click_through {
set_ignore_cursor(&window, true);
}
log::info!(
"{label} created at ({}, {}) size {}x{} dpi {}",
placement.pos.x,
@@ -438,7 +442,6 @@ pub fn mark_ready(app: &AppHandle, label: &str) -> Result<(), String> {
let provider = label
.strip_prefix(BUBBLE_LABEL_PREFIX)
.and_then(ProviderId::from_slug)
.filter(|p| p.metadata().live_usage)
.ok_or_else(|| "not a bubble window".to_string())?;
if app.get_webview_window(label).is_none() {
return Err("no such bubble window".into());
@@ -486,6 +489,64 @@ pub fn apply(app: &AppHandle, action: Action) {
}
}
/// Show every ready bubble again and put it back on top, unless the
/// bubbles are meant to be hidden. Another always-on-top window, or a shell
/// action that hid ours, can otherwise leave a bubble covered or hidden for
/// good. A bubble still waiting for its page is left to the ready handshake.
/// Visibility is read on the main thread, after any hide queued before.
pub fn bring_back(app: &AppHandle) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || {
let shows = runtime_of(&handle).is_some_and(|r| r.with_state(|s| s.visibility.shows()));
if !shows {
return;
}
for (_, window) in windows(&handle) {
if with_pending(|p| p.contains_key(window.label())) {
continue;
}
show(&window);
raise(&window);
}
});
if let Err(err) = queued {
log::warn!("bubble bring-back could not reach the main thread: {err}");
}
}
fn raise(window: &WebviewWindow) {
#[cfg(windows)]
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
// Re-setting the same level is a no-op, so drop it and set it again.
#[cfg(not(windows))]
let result = window
.set_always_on_top(false)
.and_then(|()| window.set_always_on_top(true))
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} not raised: {err}", window.label());
}
}
/// Make every bubble ignore the mouse, or take it again.
pub fn set_click_through(app: &AppHandle, ignore: bool) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || {
for (_, window) in windows(&handle) {
set_ignore_cursor(&window, ignore);
}
});
if let Err(err) = queued {
log::warn!("bubble click-through could not reach the main thread: {err}");
}
}
fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) {
if let Err(err) = window.set_ignore_cursor_events(ignore) {
log::warn!("{} click-through not set: {err}", window.label());
}
}
fn show(window: &WebviewWindow) {
#[cfg(windows)]
let result = platform::windows::show_no_activate(window).map_err(|e| e.to_string());
+11 -13
View File
@@ -24,7 +24,7 @@ pub fn get_bubble_state(
runtime: State<'_, Arc<Runtime>>,
provider: String,
) -> Result<BubblePayload, String> {
let provider = live_provider(&provider)?;
let provider = parse_provider(&provider)?;
let scale = window.scale_factor().map_err(|err| err.to_string())?;
Ok(runtime.bubble_payload(provider, scale))
}
@@ -66,7 +66,7 @@ pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> {
/// running inside the webview's IPC callback.
#[tauri::command]
pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
let provider = live_provider(&slug)?;
let provider = parse_provider(&slug)?;
runtime.bubble_clicked(provider);
Ok(())
}
@@ -74,7 +74,7 @@ pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Res
/// The page is about to start an OS drag of a bubble.
#[tauri::command]
pub fn bubble_drag_start(app: AppHandle, slug: String) -> Result<(), String> {
let provider = live_provider(&slug)?;
let provider = parse_provider(&slug)?;
bubble::drag_start(&app, provider);
Ok(())
}
@@ -96,10 +96,8 @@ fn resize_delta(dir: i32) -> Result<i32, String> {
}
}
fn live_provider(slug: &str) -> Result<ProviderId, String> {
ProviderId::from_slug(slug)
.filter(|p| p.metadata().live_usage)
.ok_or_else(|| "unknown provider".to_string())
fn parse_provider(slug: &str) -> Result<ProviderId, String> {
ProviderId::from_slug(slug).ok_or_else(|| "unknown provider".to_string())
}
/// One log line: control characters become spaces, so a message cannot
@@ -117,12 +115,12 @@ mod tests {
use super::*;
#[test]
fn only_live_providers_are_accepted() {
assert_eq!(live_provider("claude"), Ok(ProviderId::Claude));
assert_eq!(live_provider("chatgpt"), Ok(ProviderId::ChatGpt));
assert!(live_provider("opencode-go").is_err());
assert!(live_provider("").is_err());
assert!(live_provider("CLAUDE").is_err());
fn only_known_providers_are_accepted() {
assert_eq!(parse_provider("claude"), Ok(ProviderId::Claude));
assert_eq!(parse_provider("chatgpt"), Ok(ProviderId::ChatGpt));
assert!(parse_provider("codex").is_err());
assert!(parse_provider("").is_err());
assert!(parse_provider("CLAUDE").is_err());
}
#[test]
+1 -6
View File
@@ -163,7 +163,6 @@ pub fn default_position(
let stagger = match model {
ProviderId::Claude => 0,
ProviderId::ChatGpt => height_px + STAGGER_GAP,
ProviderId::OpenCodeGo => 2 * (height_px + STAGGER_GAP),
};
let x = work_area.r - width_px - STAGGER_GAP;
let y = work_area.b - height_px - STAGGER_GAP - stagger;
@@ -210,7 +209,7 @@ pub struct SnapInput {
pub dpi: u32,
/// Docked taskbar edge and rect, when the platform reports one.
pub taskbar: Option<(Edge, Rect)>,
/// Outer rects of the other bubbles, in `ProviderId::LIVE_USAGE` order.
/// Outer rects of the other bubbles, in `ProviderId::ALL` order.
pub peers: Vec<Rect>,
}
@@ -499,10 +498,6 @@ mod tests {
default_position(WA, w, h, ProviderId::ChatGpt),
Point::new(1696, 1040 - 66 - 24 - 90)
);
assert_eq!(
default_position(WA, w, h, ProviderId::OpenCodeGo),
Point::new(1696, 1040 - 66 - 24 - 180)
);
}
#[test]
+2 -3
View File
@@ -24,7 +24,6 @@ pub struct LocaleStrings {
pub providers: String,
pub claude_label: String,
pub chatgpt_label: String,
pub opencode_go_label: String,
pub settings: String,
pub start_with_windows: String,
pub start_at_login: String,
@@ -57,6 +56,7 @@ pub struct LocaleStrings {
pub exit: String,
pub restart: String,
pub show_widget: String,
pub click_through: String,
pub session_window: String,
pub weekly_window: String,
pub now: String,
@@ -124,7 +124,6 @@ impl LocaleStrings {
match provider {
ProviderId::Claude => &self.claude_label,
ProviderId::ChatGpt => &self.chatgpt_label,
ProviderId::OpenCodeGo => &self.opencode_go_label,
}
}
}
@@ -141,7 +140,6 @@ fn english_strings() -> LocaleStrings {
providers: "Providers".into(),
claude_label: "Claude Code".into(),
chatgpt_label: "Codex".into(),
opencode_go_label: "OpenCode Go".into(),
settings: "Settings".into(),
start_with_windows: "Start with Windows".into(),
start_at_login: "Start at login".into(),
@@ -172,6 +170,7 @@ fn english_strings() -> LocaleStrings {
exit: "Exit".into(),
restart: "Restart".into(),
show_widget: "Show widget".into(),
click_through: "Click-through".into(),
session_window: "5h".into(),
weekly_window: "7d".into(),
now: "now".into(),
+16 -8
View File
@@ -23,6 +23,7 @@ pub enum MenuCommand {
SizeLarger,
SizeReset,
ToggleWidget,
ToggleClickThrough,
Restart,
Exit,
}
@@ -31,7 +32,7 @@ impl MenuCommand {
/// The item id: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`,
/// `reset-position`, `version`, `update-interval:<secs|off>`,
/// `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`,
/// `restart` or `exit`.
/// `click-through`, `restart` or `exit`.
pub fn id(self) -> String {
match self {
MenuCommand::Refresh => "refresh".into(),
@@ -46,6 +47,7 @@ impl MenuCommand {
MenuCommand::SizeLarger => "size-larger".into(),
MenuCommand::SizeReset => "size-reset".into(),
MenuCommand::ToggleWidget => "toggle-widget".into(),
MenuCommand::ToggleClickThrough => "click-through".into(),
MenuCommand::Restart => "restart".into(),
MenuCommand::Exit => "exit".into(),
}
@@ -64,6 +66,7 @@ impl MenuCommand {
"size-larger" => Some(MenuCommand::SizeLarger),
"size-reset" => Some(MenuCommand::SizeReset),
"toggle-widget" => Some(MenuCommand::ToggleWidget),
"click-through" => Some(MenuCommand::ToggleClickThrough),
"restart" => Some(MenuCommand::Restart),
"exit" => Some(MenuCommand::Exit),
_ => None,
@@ -205,7 +208,7 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
MenuCommand::ToggleProvider(p),
s.provider_label(p),
settings.is_provider_enabled(p),
p.metadata().live_usage,
true,
)
})
.collect();
@@ -282,6 +285,12 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
settings.widget_visible,
true,
),
check(
MenuCommand::ToggleClickThrough,
&s.click_through,
settings.click_through,
true,
),
MenuEntry::Separator,
action(MenuCommand::Restart, &s.restart, true),
action(MenuCommand::Exit, &s.exit, true),
@@ -360,8 +369,8 @@ mod tests {
poll_interval_ms: POLL_15_MIN,
update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
show_codex: true,
show_opencode_go: true,
widget_visible: false,
click_through: true,
..Settings::default()
};
let m = menu(&settings, UpdateStatus::Idle);
@@ -371,11 +380,9 @@ mod tests {
assert!(!checked(&m, "update-interval:off"));
assert!(checked(&m, "provider:claude"));
assert!(checked(&m, "provider:chatgpt"));
// OpenCode Go mirrors its flag but stays greyed.
assert!(checked(&m, "provider:opencode-go"));
assert!(!enabled(&m, "provider:opencode-go"));
assert!(checked(&m, "autostart"));
assert!(!checked(&m, "toggle-widget"));
assert!(checked(&m, "click-through"));
}
#[test]
@@ -440,6 +447,7 @@ mod tests {
"Settings",
"Controls",
"Show widget",
"Click-through",
"-",
"Restart",
"Exit"
@@ -524,7 +532,6 @@ mod tests {
"Providers >",
" Claude Code [provider:claude] on",
" Codex [provider:chatgpt] off",
" OpenCode Go [provider:opencode-go] off grey",
"Settings >",
&autostart,
" Reset position [reset-position]",
@@ -543,12 +550,13 @@ mod tests {
let got: Vec<&str> = lines.iter().map(String::as_str).collect();
assert_eq!(got[..expected.len()], expected);
let tail: Vec<&str> = got[expected.len()..].to_vec();
assert_eq!(tail.len(), 9);
assert_eq!(tail.len(), 10);
assert!(tail[..5].iter().all(|l| l.ends_with(" hint")), "{tail:?}");
assert_eq!(
tail[5..],
[
"Show widget [toggle-widget] on",
"Click-through [click-through] off",
"-",
"Restart [restart]",
"Exit [exit]"
-10
View File
@@ -14,7 +14,6 @@ use crate::usage::ProviderId;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60;
/// Placeholder for the head label and weekly countdown before the first poll.
const PENDING: &str = "…";
@@ -25,7 +24,6 @@ const PENDING: &str = "…";
pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) {
match provider {
ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS),
ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS),
}
}
@@ -339,12 +337,4 @@ mod tests {
assert!(panel.get(key).is_some(), "missing {key}");
}
}
#[test]
fn opencode_go_uses_weekly_and_monthly_windows() {
assert_eq!(
window_durations_secs(ProviderId::OpenCodeGo),
(604_800, 2_592_000)
);
}
}
+39 -68
View File
@@ -40,9 +40,6 @@ fn default_show_claude() -> bool {
fn default_show_codex() -> bool {
false
}
fn default_show_opencode_go() -> bool {
false
}
fn default_widget_visible() -> bool {
true
}
@@ -60,7 +57,6 @@ fn default_update_check_interval_secs() -> Option<u64> {
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
pub opencode_go: Option<(i32, i32)>,
}
impl BubblePositions {
@@ -68,27 +64,23 @@ impl BubblePositions {
match provider {
ProviderId::Claude => self.claude,
ProviderId::ChatGpt => self.codex,
ProviderId::OpenCodeGo => self.opencode_go,
}
}
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
match provider {
ProviderId::Claude => self.claude = Some(pos),
ProviderId::ChatGpt => self.codex = Some(pos),
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
}
}
pub fn reset(&mut self, provider: ProviderId) {
match provider {
ProviderId::Claude => self.claude = None,
ProviderId::ChatGpt => self.codex = None,
ProviderId::OpenCodeGo => self.opencode_go = None,
}
}
pub fn reset_all(&mut self) {
self.claude = None;
self.codex = None;
self.opencode_go = None;
}
/// Drop any saved position whose top-left no longer falls on one of
@@ -115,8 +107,6 @@ pub struct Settings {
pub show_claude_code: bool,
#[serde(default = "default_show_codex")]
pub show_codex: bool,
#[serde(default = "default_show_opencode_go")]
pub show_opencode_go: bool,
#[serde(default)]
pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")]
@@ -129,6 +119,9 @@ pub struct Settings {
pub update_check_interval_secs: Option<u64>,
#[serde(default = "default_widget_visible")]
pub widget_visible: bool,
/// The bubbles ignore the mouse, so clicks reach the windows below.
#[serde(default)]
pub click_through: bool,
}
impl Default for Settings {
@@ -136,13 +129,13 @@ impl Default for Settings {
Self {
show_claude_code: default_show_claude(),
show_codex: default_show_codex(),
show_opencode_go: default_show_opencode_go(),
bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(),
last_update_check_unix: None,
update_check_interval_secs: default_update_check_interval_secs(),
widget_visible: default_widget_visible(),
click_through: false,
}
}
}
@@ -152,7 +145,6 @@ impl Settings {
match provider {
ProviderId::Claude => self.show_claude_code,
ProviderId::ChatGpt => self.show_codex,
ProviderId::OpenCodeGo => self.show_opencode_go,
}
}
@@ -160,30 +152,23 @@ impl Settings {
match provider {
ProviderId::Claude => self.show_claude_code = enabled,
ProviderId::ChatGpt => self.show_codex = enabled,
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
}
}
pub fn toggle_provider(&mut self, provider: ProviderId) {
/// Menu toggle for a provider. Unchecking the last enabled provider is
/// undone so the app always shows something. Returns the provider's new
/// state.
pub fn toggle_provider(&mut self, provider: ProviderId) -> bool {
let enabled = !self.is_provider_enabled(provider);
self.set_provider_enabled(provider, enabled);
}
/// Menu toggle for a provider. Providers without live usage cannot be
/// toggled, and unchecking the last enabled live provider is undone so
/// the app always shows something. Returns the provider's new state.
pub fn toggle_live_provider(&mut self, provider: ProviderId) -> bool {
if provider.metadata().live_usage {
self.toggle_provider(provider);
if !self.has_enabled_live_provider() {
self.set_provider_enabled(provider, true);
}
if !self.has_enabled_provider() {
self.set_provider_enabled(provider, true);
}
self.is_provider_enabled(provider)
}
pub fn has_enabled_live_provider(&self) -> bool {
ProviderId::LIVE_USAGE
pub fn has_enabled_provider(&self) -> bool {
ProviderId::ALL
.iter()
.any(|provider| self.is_provider_enabled(*provider))
}
@@ -310,8 +295,8 @@ fn parse(content: &str) -> Option<Settings> {
/// Enforce the invariants a hand-edited file may break.
fn normalise(mut settings: Settings) -> Settings {
// At least one live provider must be visible. Otherwise the app has nothing to show.
if !settings.has_enabled_live_provider() {
// At least one provider must be visible. Otherwise the app has nothing to show.
if !settings.has_enabled_provider() {
settings.show_claude_code = true;
}
// Clamp bubble size to safe range in case settings.json was hand-edited.
@@ -408,7 +393,6 @@ mod tests {
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert!(!settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::Claude),
Some((10, 20))
@@ -417,30 +401,32 @@ mod tests {
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
}
#[test]
fn three_provider_settings_json_loads() {
fn retired_opencode_go_keys_are_ignored() {
let json = r#"{
"show_claude_code": true,
"show_codex": false,
"show_claude_code": false,
"show_codex": true,
"show_opencode_go": true,
"bubble_positions": {
"codex": [30, 40],
"opencode_go": [50, 60]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("new settings json should parse");
serde_json::from_str(json).expect("settings with retired keys should parse");
assert!(settings.show_claude_code);
assert!(!settings.show_codex);
assert!(settings.show_opencode_go);
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert_eq!(
settings.bubble_positions.get(ProviderId::OpenCodeGo),
Some((50, 60))
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
let keys = serde_json::to_value(&settings).expect("serialise");
assert!(keys.get("show_opencode_go").is_none());
assert!(keys["bubble_positions"].get("opencode_go").is_none());
}
#[test]
@@ -518,10 +504,9 @@ mod tests {
}
#[test]
fn normalise_restores_a_live_provider_and_clamps_size() {
fn normalise_restores_a_provider_and_clamps_size() {
let settings = parse_and_normalise(
r#"{"show_claude_code": false, "show_codex": false, "show_opencode_go": true,
"bubble_size_logical": 9000}"#,
r#"{"show_claude_code": false, "show_codex": false, "bubble_size_logical": 9000}"#,
);
assert!(settings.show_claude_code);
assert_eq!(settings.bubble_size_logical, MAX_BUBBLE_SIZE);
@@ -539,11 +524,11 @@ mod tests {
[
"bubble_positions",
"bubble_size_logical",
"click_through",
"last_update_check_unix",
"poll_interval_ms",
"show_claude_code",
"show_codex",
"show_opencode_go",
"update_check_interval_secs",
"widget_visible",
]
@@ -557,15 +542,13 @@ mod tests {
#[test]
fn offscreen_positions_are_dropped_against_monitors() {
let mut settings = parse_and_normalise(
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100],
"opencode_go": [2000, 500]}}"#,
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100]}}"#,
);
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
drop_offscreen_positions(&mut settings, &monitors);
let p = &settings.bubble_positions;
assert_eq!(p.get(ProviderId::Claude), Some((100, 100)));
assert_eq!(p.get(ProviderId::ChatGpt), None);
assert_eq!(p.get(ProviderId::OpenCodeGo), Some((2000, 500)));
}
#[test]
@@ -617,27 +600,15 @@ mod tests {
}
#[test]
fn toggle_keeps_provider_flags_independent() {
fn toggle_never_disables_the_last_provider() {
let mut settings = Settings::default();
settings.toggle_provider(ProviderId::ChatGpt);
assert!(settings.is_provider_enabled(ProviderId::ChatGpt));
settings.toggle_provider(ProviderId::Claude);
assert!(!settings.is_provider_enabled(ProviderId::Claude));
assert!(settings.has_enabled_live_provider());
settings.toggle_provider(ProviderId::ChatGpt);
assert!(!settings.has_enabled_live_provider());
}
#[test]
fn live_toggle_never_disables_the_last_live_provider() {
let mut settings = Settings::default();
assert!(settings.toggle_live_provider(ProviderId::Claude));
assert!(settings.show_claude_code);
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
assert!(!settings.toggle_live_provider(ProviderId::Claude));
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
// OpenCode Go has no live usage, so its flag never changes.
assert!(!settings.toggle_live_provider(ProviderId::OpenCodeGo));
assert!(!settings.show_opencode_go);
assert!(settings.toggle_provider(ProviderId::ChatGpt));
assert!(!settings.toggle_provider(ProviderId::Claude));
assert!(settings.has_enabled_provider());
// ChatGPT is now the only enabled provider, so it stays on.
assert!(settings.toggle_provider(ProviderId::ChatGpt));
assert!(settings.show_codex);
assert!(settings.toggle_provider(ProviderId::Claude));
assert!(!settings.toggle_provider(ProviderId::ChatGpt));
}
}
-4
View File
@@ -79,10 +79,6 @@ impl Alert {
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
),
ProviderId::OpenCodeGo => (
strings.opencode_go_label.clone(),
strings.update_failed.clone(),
),
},
Alert::UpdateApplied { version } => (
strings.update_applied_title.clone(),
-2
View File
@@ -19,7 +19,6 @@ pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
Color::from_hex("#1A1A1A")
}
}
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
}
}
@@ -74,7 +73,6 @@ mod tests {
fn safe_band_uses_theme_aware_accent() {
assert_eq!(hex(ProviderId::ChatGpt, true, 10.0), "#E5E5E5");
assert_eq!(hex(ProviderId::ChatGpt, false, 10.0), "#1A1A1A");
assert_eq!(hex(ProviderId::OpenCodeGo, false, 0.0), "#3BAE75");
assert_eq!(
accent_color_for(ProviderId::Claude, false),
accent_color_for(ProviderId::Claude, true)
+12 -1
View File
@@ -2,10 +2,13 @@
//! fullscreen window of another process is in the foreground, and hides the
//! bubbles while one is. They come back without activation when it leaves,
//! unless the user hid them in the meantime.
//!
//! The same thread brings the bubbles back every minute while they should
//! show, in case another window covered or hid them.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::core::visibility::Event;
use crate::platform::{self, FullscreenCheck};
@@ -14,13 +17,21 @@ use crate::runtime::Runtime;
/// How often the foreground window is checked.
pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500);
/// How often shown bubbles are brought back on top.
pub const BRING_BACK_INTERVAL: Duration = Duration::from_secs(60);
/// Start the check thread. It runs until the process starts exiting.
pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
std::thread::Builder::new()
.name("fullscreen".into())
.spawn(move || {
let mut last_bring_back = Instant::now();
while !runtime.is_exiting() {
tick(&runtime);
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
last_bring_back = Instant::now();
runtime.bring_back_bubbles();
}
std::thread::sleep(CHECK_INTERVAL);
}
})
+23 -3
View File
@@ -28,9 +28,9 @@ use windows::Win32::UI::Shell::{
use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
ShowWindow, GWL_EXSTYLE, GWL_STYLE, MA_NOACTIVATE, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE,
SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
ShowWindow, GWL_EXSTYLE, GWL_STYLE, HWND_TOPMOST, MA_NOACTIVATE, MB_ICONERROR, MB_OK,
MB_SETFOREGROUND, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER,
SW_HIDE, SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
};
@@ -293,6 +293,26 @@ pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
Ok(())
}
/// Put a bubble back at the top of the always-on-top band, above any
/// topmost window that came after it, without activating it.
pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
// SAFETY: `hwnd` is a live window of this process; nothing moves,
// resizes or activates.
unsafe {
SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
)
}
.map_err(io::Error::from)
}
/// Hide a bubble (see [`show_no_activate`]).
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
+27 -15
View File
@@ -139,7 +139,7 @@ struct BubbleSnapshot {
impl BubbleSnapshot {
fn of(state: &AppState) -> Self {
Self {
providers: enabled_live_providers(&state.settings),
providers: enabled_providers(&state.settings),
usage: state.usage.clone(),
strings: state.strings.clone(),
is_dark: state.is_dark,
@@ -264,7 +264,7 @@ impl Runtime {
};
let (views, menu_snapshot) = runtime.with_state(|s| {
(
tray::views(&enabled_live_providers(&s.settings), &s.usage, &s.strings),
tray::views(&enabled_providers(&s.settings), &s.usage, &s.strings),
MenuSnapshot::of(s),
)
});
@@ -433,6 +433,7 @@ impl Runtime {
MenuCommand::SizeLarger => self.resize_bubbles(geom::RESIZE_STEP_LOGICAL),
MenuCommand::SizeReset => self.set_bubble_size(DEFAULT_BUBBLE_SIZE),
MenuCommand::ToggleWidget => self.toggle_widget(),
MenuCommand::ToggleClickThrough => self.toggle_click_through(),
MenuCommand::Restart => self.restart(),
MenuCommand::Exit => self.exit(),
}
@@ -468,13 +469,10 @@ impl Runtime {
});
}
/// Toggle a live provider, keeping at least one enabled, then create or
/// Toggle a provider, keeping at least one enabled, then create or
/// destroy its bubble, update the tray and poll.
fn toggle_provider(self: &Arc<Self>, provider: ProviderId) {
if !provider.metadata().live_usage {
return;
}
let enabled = self.update_settings(|s| s.toggle_live_provider(provider));
let enabled = self.update_settings(|s| s.toggle_provider(provider));
if enabled {
bubble::spawn_create(&self.ui.app, provider);
} else if let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) {
@@ -499,6 +497,21 @@ impl Runtime {
self.apply_visibility(event);
}
/// Flip whether the bubbles let the mouse through, and remember it.
fn toggle_click_through(&self) {
let ignore = self.update_settings(|s| {
s.click_through = !s.click_through;
s.click_through
});
bubble::set_click_through(&self.ui.app, ignore);
}
/// Show the bubbles again and put them back on top, unless they are
/// meant to be hidden.
pub fn bring_back_bubbles(&self) {
bubble::bring_back(&self.ui.app);
}
/// Apply a visibility event to every bubble. A user show or hide is
/// also saved as `widget_visible`.
pub fn apply_visibility(&self, event: visibility::Event) {
@@ -835,9 +848,9 @@ pub fn bubble_label(provider: ProviderId) -> String {
format!("{BUBBLE_LABEL_PREFIX}{}", provider.slug())
}
/// Live providers the user has enabled, in display order.
pub fn enabled_live_providers(settings: &Settings) -> Vec<ProviderId> {
ProviderId::LIVE_USAGE
/// Providers the user has enabled, in display order.
pub fn enabled_providers(settings: &Settings) -> Vec<ProviderId> {
ProviderId::ALL
.into_iter()
.filter(|p| settings.is_provider_enabled(*p))
.collect()
@@ -1008,7 +1021,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
update_interval_secs: state.settings.update_check_interval_secs,
last_update_check_unix: state.settings.last_update_check_unix,
};
let providers = enabled_live_providers(&state.settings);
let providers = enabled_providers(&state.settings);
let (tx, rx) = mpsc::channel();
let runtime = Arc::new(Runtime {
@@ -1150,18 +1163,17 @@ mod tests {
}
#[test]
fn enabled_providers_keep_display_order_and_skip_non_live_ones() {
fn enabled_providers_keep_display_order() {
let mut settings = Settings {
show_codex: true,
show_opencode_go: true,
..Settings::default()
};
assert_eq!(
enabled_live_providers(&settings),
enabled_providers(&settings),
[ProviderId::Claude, ProviderId::ChatGpt]
);
settings.show_claude_code = false;
assert_eq!(enabled_live_providers(&settings), [ProviderId::ChatGpt]);
assert_eq!(enabled_providers(&settings), [ProviderId::ChatGpt]);
}
#[test]
+5 -11
View File
@@ -37,7 +37,6 @@ pub struct TrayView {
pub fn views(enabled: &[ProviderId], usage: &UsageState, strings: &LocaleStrings) -> Vec<TrayView> {
enabled
.iter()
.filter(|p| p.metadata().live_usage)
.map(|&provider| {
let entry = usage.snapshots.get(&provider);
TrayView {
@@ -106,7 +105,7 @@ struct Plan {
fn plan(shown: &HashMap<ProviderId, Shown>, views: &[TrayView]) -> Plan {
let mut plan = Plan {
remove: ProviderId::LIVE_USAGE
remove: ProviderId::ALL
.into_iter()
.filter(|p| shown.contains_key(p) && !views.iter().any(|v| v.provider == *p))
.collect(),
@@ -153,7 +152,7 @@ impl Trays {
/// The providers that currently have an icon.
pub fn providers(&self) -> Vec<ProviderId> {
let shown = self.shown();
ProviderId::LIVE_USAGE
ProviderId::ALL
.into_iter()
.filter(|p| shown.contains_key(p))
.collect()
@@ -319,13 +318,8 @@ mod tests {
#[test]
fn views_follow_enabled_providers_and_last_poll() {
let s = strings();
let all = [
ProviderId::Claude,
ProviderId::ChatGpt,
ProviderId::OpenCodeGo,
];
let v = views(&all, &usage(42.0, true), &s);
assert_eq!(v.len(), 2, "OpenCode Go never gets an icon");
let v = views(&ProviderId::ALL, &usage(42.0, true), &s);
assert_eq!(v.len(), 2);
assert_eq!(v[0].provider, ProviderId::Claude);
assert_eq!(v[0].percent, Some(42.0));
assert_eq!(
@@ -335,7 +329,7 @@ mod tests {
assert_eq!(v[1].provider, ProviderId::ChatGpt);
assert_eq!(v[1].percent, None);
// A failed last poll greys every badge to the track.
let v = views(&all, &usage(42.0, false), &s);
let v = views(&ProviderId::ALL, &usage(42.0, false), &s);
assert_eq!(v[0].percent, None);
assert!(views(&[ProviderId::ChatGpt], &usage(1.0, true), &s)
.iter()
+2 -18
View File
@@ -31,16 +31,13 @@ impl Registry {
settings: &Settings,
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
let mut out = Vec::new();
for provider in ProviderId::LIVE_USAGE {
for provider in ProviderId::ALL {
if !settings.is_provider_enabled(provider) {
continue;
}
let result = match provider {
ProviderId::Claude => self.claude.poll(http),
ProviderId::ChatGpt => self.chatgpt.poll(http),
ProviderId::OpenCodeGo => {
unreachable!("OpenCode Go has no live usage provider yet")
}
};
out.push((provider, result));
}
@@ -56,7 +53,6 @@ impl Registry {
let locator = match id {
ProviderId::Claude => self.claude.locator(),
ProviderId::ChatGpt => self.chatgpt.locator(),
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
};
match locator.first_available() {
Some(src) => orchestrator.refresh(src),
@@ -80,18 +76,6 @@ mod tests {
use super::*;
use crate::net::USER_AGENT;
#[test]
fn live_usage_excludes_opencode_go() {
// `poll_enabled` relies on this for its `unreachable!` arm.
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
for provider in ProviderId::ALL {
assert_eq!(
ProviderId::LIVE_USAGE.contains(&provider),
provider.metadata().live_usage
);
}
}
/// Polls the real providers with the credentials on this machine. Run by
/// hand: `cargo test -- --ignored live_poll --nocapture`.
#[test]
@@ -104,7 +88,7 @@ mod tests {
}
let mut registry = Registry::with_defaults();
let results = registry.poll_enabled(&http, &settings);
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
assert_eq!(results.len(), ProviderId::ALL.len());
for (provider, result) in results {
match result {
Ok(w) => println!(
+1 -51
View File
@@ -12,19 +12,15 @@ pub enum ProviderId {
Claude,
#[serde(rename = "chatgpt")]
ChatGpt,
#[serde(rename = "opencode-go")]
OpenCodeGo,
}
impl ProviderId {
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
pub const ALL: [Self; 2] = [Self::Claude, Self::ChatGpt];
pub fn slug(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::ChatGpt => "chatgpt",
Self::OpenCodeGo => "opencode-go",
}
}
@@ -32,51 +28,6 @@ impl ProviderId {
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.slug() == slug)
}
pub fn metadata(self) -> ProviderMetadata {
match self {
Self::Claude => ProviderMetadata {
id: self,
slug: "claude",
default_enabled: true,
tray_icon_id: 1,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::ChatGpt => ProviderMetadata {
id: self,
slug: "chatgpt",
default_enabled: false,
tray_icon_id: 2,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::OpenCodeGo => ProviderMetadata {
id: self,
slug: "opencode-go",
default_enabled: false,
tray_icon_id: 3,
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
live_usage: false,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderDisplayMode {
TwoWindow,
WeeklyMonthlyFourBar,
}
#[derive(Clone, Copy, Debug)]
pub struct ProviderMetadata {
pub id: ProviderId,
pub slug: &'static str,
pub default_enabled: bool,
pub tray_icon_id: u32,
pub display_mode: ProviderDisplayMode,
pub live_usage: bool,
}
/// One usage window: how much you've consumed (0–100), and when it resets.
@@ -110,7 +61,6 @@ mod tests {
let json = serde_json::to_string(&provider).expect("serialise");
assert_eq!(json, format!("\"{}\"", provider.slug()));
assert_eq!(ProviderId::from_slug(provider.slug()), Some(provider));
assert_eq!(provider.metadata().slug, provider.slug());
}
assert_eq!(ProviderId::from_slug("codex"), None);
}
+13
View File
@@ -10,6 +10,7 @@ import {
cssPx,
headLabelText,
headMetrics,
outlinePath,
pulseActive,
pulseT,
remainingFraction,
@@ -156,6 +157,18 @@ describe('tailLayout', () => {
});
});
describe('outlinePath', () => {
it('joins a full-height head to a centred tail with rounded end corners', () => {
// h 100: tail 60 tall from y 20 to 80, end corners of radius 12, and the
// tail edges meet the head circle at x = 50 + sqrt(50² - 30²) = 90.
assert.equal(
outlinePath(300, 100),
'M 90 20 L 288 20 A 12 12 0 0 1 300 32 L 300 68 A 12 12 0 0 1 288 80 ' +
'L 90 80 A 50 50 0 1 1 90 20 Z',
);
});
});
describe('scale and cssPx', () => {
it('truncates like the Rust scale', () => {
assert.equal(scale(200, 96), 200);
+7 -5
View File
@@ -1,6 +1,7 @@
/* Bubble palette. Accent and fill colours come from the payload. */
:root[data-theme='dark'] {
--bg: #1f1f1f;
/* 60% opaque, so the desktop shows through; text and bars stay solid. */
--bg: rgb(31 31 31 / 60%);
--track: #2c2c2c;
--time-track: #404040;
--time-fill: #b0b0b0;
@@ -9,7 +10,7 @@
}
:root[data-theme='light'] {
--bg: #f3f3f3;
--bg: rgb(243 243 243 / 60%);
--track: #e2e2e2;
--time-track: #e0e0e0;
--time-fill: #666666;
@@ -26,7 +27,7 @@ body {
background: transparent;
user-select: none;
-webkit-user-select: none;
/* Only the pill takes input: the transparent stadium corners are dead. */
/* Only the pill takes input: the area outside its outline is dead. */
pointer-events: none;
}
@@ -34,7 +35,8 @@ body {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 9999px;
/* bubble.js clips this to the head-and-tail outline, which also bounds
the hit area. */
background: var(--bg);
font-family: 'Segoe UI', system-ui, sans-serif;
cursor: move;
@@ -42,7 +44,7 @@ body {
}
/* Every pointer event targets the pill itself, whose hit area follows its
rounded outline. */
clipped outline. */
.pill * {
pointer-events: none;
}
+3
View File
@@ -8,6 +8,7 @@ import {
cssPx,
headLabelText,
headMetrics,
outlinePath,
pulseActive,
pulseT,
remainingFraction,
@@ -217,6 +218,8 @@ function relayout(p) {
tail = tailLayout(canvas, (text, px, weight) => measure(text, px, weight));
layoutKey = key;
const outline = outlinePath(cssPx(p.canvasW, p.dpi), cssPx(p.canvasH, p.dpi));
pill.style.clipPath = `path('${outline}')`;
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
placeCircle(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(ringArc, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
+35
View File
@@ -30,6 +30,12 @@ const WHEEL_MODE_PX = [1, 16, 800];
/** Usage at or above this percent pulses. */
export const PULSE_THRESHOLD_PCT = 95;
/** Tail height as a share of the head diameter. */
export const TAIL_HEIGHT_RATIO = 0.6;
/** Tail end corner radius as a share of the tail height. */
const TAIL_CORNER_RATIO = 0.2;
/** Pulse period in ticks and the share of the way to white at the peak. */
const PULSE_PERIOD = 24;
const PULSE_STRENGTH = 0.3;
@@ -195,6 +201,35 @@ export function headMetrics(canvas) {
};
}
/**
* Outline of the bubble: a round head the full height of the window, with a
* narrower, vertically centred tail running to the right edge. The tail's
* end has small rounded corners. Units are whatever `w` and `h` are in.
* @param {number} w Window width.
* @param {number} h Window height, which is also the head diameter.
* @returns {string} SVG path data for CSS `clip-path: path(...)`.
*/
export function outlinePath(w, h) {
const r = h / 2;
const tailH = h * TAIL_HEIGHT_RATIO;
const top = (h - tailH) / 2;
const bottom = top + tailH;
const corner = tailH * TAIL_CORNER_RATIO;
// Where the tail's top and bottom edges meet the head circle.
const joinX = r + Math.sqrt(r * r - (r - top) ** 2);
const n = (/** @type {number} */ v) => Number(v.toFixed(2));
return [
`M ${n(joinX)} ${n(top)}`,
`L ${n(w - corner)} ${n(top)}`,
`A ${n(corner)} ${n(corner)} 0 0 1 ${n(w)} ${n(top + corner)}`,
`L ${n(w)} ${n(bottom - corner)}`,
`A ${n(corner)} ${n(corner)} 0 0 1 ${n(w - corner)} ${n(bottom)}`,
`L ${n(joinX)} ${n(bottom)}`,
`A ${n(r)} ${n(r)} 0 1 1 ${n(joinX)} ${n(top)}`,
'Z',
].join(' ');
}
/**
* Geometry of the tail: the weekly usage bar and remaining-time bar with
* their right-aligned texts. When the tail is too narrow, the text column