Compare commits

...
8 Commits
Author SHA1 Message Date
tiennm99 5c2b14fc03 feat(ui): show remaining time progress 2026-05-23 22:31:37 +07:00
tiennm99 51889d3c39 fix(ui): scale weekly bar thickness 2026-05-23 21:43:11 +07:00
tiennm99 6661a7a10b chore: bump version to 0.3.1 2026-05-23 21:07:05 +07:00
tiennm99 6cafffc883 fix(bubble): show 7d percent at default size and 125% DPI
v0.3.0 introduced a tail 7d% reading but the layout-collapse guard
fired at every common bubble configuration — at the default 200-logical
size on both 100% and 125% DPI, after reserving the CJK worst-case
countdown column ("999시간") and the "100%" text rect, the tail had
less than 20 logical of bar room left. The guard collapsed the % rect
to zero width and the paint code's `if rect.right > rect.left` skip
ran on every frame, so the feature was effectively dead on arrival
for the majority of users.

The 20-logical bar minimum was the pre-feature bar floor, used to
guarantee a readable bar at very small bubble sizes. It does not need
to apply when the % is shown — the % is the actual data and the bar
becomes secondary visual context. Split into two thresholds:

- `bar_min_with_pct = 8 logical` decides whether the % can fit. With
  8 logical of bar room the bar still renders as a short pill.
- `bar_min = 20 logical` only applies on the fallback (140-logical
  minimum bubble) path where the % has been dropped — preserving
  the pre-feature readable-bar behavior at the smallest size.

The bar's render floor now follows the active path (`bar_render_min`)
so a thin bar in the pct-active case does not overlap the countdown.
2026-05-23 21:06:26 +07:00
tiennm99 b58811bfe0 chore: bump version to 0.3.0 2026-05-23 20:33:10 +07:00
tiennm99 e50aa3522a feat(bubble): show 5h countdown in head and 7d percent in tail
The stadium bubble previously dropped the 5h reset countdown (only the
ring + percent were visible in the head) and never showed the 7d percent
as a number (only the tail bar fill suggested it). Two more glanceable
data points now live on the bubble face without reopening the panel.

Head: the small "5h" tag is replaced by the live 5h countdown (e.g.
"2h14m"). Falls back to the literal "5h" when no countdown is available
yet (cold start) or when the localized string would overflow the rect —
DT_NOCLIP would otherwise leak wide CJK glyphs ("4시간 32분") onto the
ring stroke at the 140-logical minimum width.

Tail: a new "X%" reading sits between the "7d" label and the bar
(layout reads "7d  62%  ▰▰▰▰▰▱▱▱   6d4h"). Foreground text color —
not the bar accent — because Codex teal #10A37F on the light theme
background only hits ~3.2:1 contrast, below WCAG AA for small text;
adjacency to the bar carries the visual grouping without hue. The text
brightens in sync with the bar fill when weekly_pct >= 95%.

compute_bubble_layout reserves room for a "100%"-sized rect between
label and bar; if that would push the bar below the 20-logical
minimum, the % rect collapses to zero width and the layout falls back
to the original label→bar→countdown geometry, so the 140-logical
bubble keeps its bar.

No new graphics dependencies; tiny-skia + GDI hybrid render path
unchanged. session_text plumbing in src/app.rs was already wired but
unused in the render — now consumed.

cargo check: clean. cargo test: 2/2. cargo clippy: 13 warnings
(unchanged baseline).
2026-05-23 20:32:31 +07:00
tiennm99 a3f1323154 chore: bump version to 0.2.0 2026-05-23 18:18:34 +07:00
tiennm99 081a70a537 feat(ui): improve bubble controls discoverability 2026-05-23 18:17:25 +07:00
12 changed files with 642 additions and 97 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.15"
version = "0.3.3"
dependencies = [
"dirs",
"embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.15"
version = "0.3.3"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
+7 -4
View File
@@ -28,7 +28,9 @@ self-updater are all written from scratch against the same public APIs
Codex usage as a percentage and a colored progress ring
- Drag anywhere — the bubble snaps to monitor work-area edges when
released
- Resize with `Ctrl + MouseWheel` on the bubble (32128 pixels)
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls**
**Make smaller / Make larger / Reset size** from the right-click menu
(140360 logical pixels)
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed models, update frequency, language,
@@ -84,10 +86,11 @@ release to snap 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, models, refresh frequency, language, "Start
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
manual "Check for updates", exit
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
Weekly), manual "Check for updates", exit
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble to resize it
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
menu, to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu
@@ -0,0 +1,91 @@
# UI/UX Improvement Plan
## Context
- Product: native Windows floating usage bubble for Claude Code/Codex.
- Current UI stack: Win32 popup/layered windows, tiny-skia drawing, GDI text, Shell tray icons.
- Current baseline: `cargo check` passes on 2026-05-23.
- Primary files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, `src/tray/*`, `src/usage_color.rs`, `src/i18n/locales/*.toml`.
## Phase 1 - Accessibility And Status Clarity
- Status: Partially Complete
- Priority: High
- Files: `src/usage_color.rs`, `src/bubble.rs`, `src/panel.rs`, `src/tray/mod.rs`, locale TOMLs.
- Improve non-color status cues for normal/warning/critical/auth/error states.
- Add richer tray tooltips: model, 5h percent/countdown, 7d percent/countdown, current state.
- Add localized strings for warning/critical labels and unavailable/auth states.
- Keep usage colors centralized in `usage_color.rs`; avoid per-surface color drift.
- Validation: contrast check for light/dark colors, manual tray tooltip check, `cargo check`.
- Completed 2026-05-23: richer tray tooltip now includes model, 5h, 7d, and left-click hint.
- Completed 2026-05-23: tray tooltip uses shorter localized tray hint text to reduce truncation risk.
## Phase 2 - Discoverability And Native Controls
- Status: Partially Complete
- Priority: High
- Files: `src/app.rs`, `src/bubble.rs`, locale TOMLs.
- Add menu items for common hidden actions: resize smaller/larger, reset size, show details.
- Add a short localized "Help" or "Controls" submenu listing drag, click, right-click, Ctrl+wheel.
- Make resize available through menu commands, not only Ctrl+MouseWheel.
- Review context-menu grouping so status/update/model/settings actions scan as separate groups.
- Validation: keyboard-access menu traversal, menu command behavior, persisted settings.
- Completed 2026-05-23: added localized Controls submenu with resize actions and disabled help rows.
- Completed 2026-05-23: disabled resize commands when they would no-op and unified menu/wheel resize through shared bubble size.
## Phase 3 - Bubble Legibility And Interaction Robustness
- Status: Planned
- Priority: Medium
- Files: `src/bubble.rs`, optional extracted bubble modules.
- Improve layout for smallest sizes: reserve stable text bounds, handle `100%`, placeholder, `!`, and long countdowns.
- Consider minimum size/shape copy update because code uses 140-360 logical width while README mentions 32-128 pixels.
- Add drag threshold and click behavior review around `WM_EXITSIZEMOVE` to reduce accidental panel opens.
- Add optional pulse reduction path if Windows animation/reduced-motion preference is available.
- Validation: manual checks at min/default/max size, 100/125/150/200% DPI, both models enabled.
## Phase 4 - Expanded Panel Redesign
- Status: Planned
- Priority: Medium
- Files: `src/panel.rs`, locale TOMLs, maybe `src/app.rs`.
- Replace fixed 280x120 assumptions with measured or wider adaptive layout.
- Make rows self-explanatory: model header, 5h and 7d labels, percent plus reset countdown.
- Add explicit error/auth/loading state rendering instead of only symbols/placeholders.
- Improve panel placement near screen edges and multi-monitor boundaries.
- Consider extracting panel layout/painting into smaller modules before behavior changes.
- Validation: all locales, long countdown text, light/dark theme, focus-loss close behavior.
## Phase 5 - Tray And Notification Polish
- Status: Planned
- Priority: Medium
- Files: `src/tray/mod.rs`, `src/tray/badge.rs`, `src/app.rs`.
- Make tray icon state readable without exact color distinction: tooltip carries exact data, icon bands remain coarse.
- Review notification throttling and text for threshold crossings.
- Ensure tray left-click/right-click behavior matches Windows notification-area conventions.
- Add manual test matrix for one-provider and two-provider modes.
- Validation: tray add/modify/delete, balloon messages, no stale icons after exit/restart.
## Phase 6 - Structure And Verification
- Status: Planned
- Priority: Medium
- Files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, docs if behavior changes.
- Split only where it reduces real risk: bubble layout/rendering/interaction and panel layout/rendering first.
- Keep public behavior stable while extracting.
- Add unit tests for pure functions where practical: color bands, size clamps, layout math, countdown formatting.
- Run `cargo check`; run `cargo test` if tests are added.
- Update README/docs after behavior changes, especially controls and size range.
- Completed 2026-05-23: added locale schema tests covering embedded locale parsing and Controls/tray strings.
## Success Criteria
- Bubble and panel remain readable at min/default/max sizes and common DPI scales.
- Warning/critical/auth/error states are understandable without relying only on color.
- Hidden interactions have menu alternatives or discoverable help text.
- Panel handles all existing locales without clipping core data.
- Tray tooltip and notifications communicate exact state.
- Source still compiles; new pure behavior has focused tests where feasible.
## Risks
- Native Win32 UI changes require manual Windows runtime verification; screenshots/tests are limited.
- `src/bubble.rs` and `src/app.rs` are large and coupled; extract before broad changes when touching multiple concerns.
- Adaptive text/layout can regress small-size readability if not verified at 140 logical width.
## Unresolved Questions
- Should the bubble stay stadium-shaped, or should compact circular mode return as an option?
- Should menu help be always present, or only shown on first run/first right-click?
- Should reduced-motion preference disable only pulse, or all nonessential animation?
+144 -28
View File
@@ -70,6 +70,9 @@ const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
const IDM_RESTART: u16 = 33;
const IDM_SIZE_SMALLER: u16 = 34;
const IDM_SIZE_LARGER: u16 = 35;
const IDM_RESET_SIZE: u16 = 36;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
@@ -340,8 +343,10 @@ fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
position: settings.bubble_positions.get(kind),
session_pct: None,
session_text: placeholder.clone(),
session_resets_at: None,
weekly_pct: None,
weekly_text: placeholder,
weekly_resets_at: None,
is_dark,
});
if hwnd != HWND::default() {
@@ -401,7 +406,7 @@ fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
}
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
update_settings(|s| s.settings.bubble_size_logical = size_logical);
set_bubble_size(size_logical);
}
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
@@ -418,13 +423,14 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_START_WITH_WINDOWS => toggle_startup(),
IDM_RESET_POSITION => reset_positions(),
IDM_VERSION_ACTION => version_action(),
IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL),
IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL),
IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE),
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
IDM_UPDATE_AUTO_HOURLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
}
IDM_UPDATE_AUTO_DAILY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
}
IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)),
IDM_UPDATE_AUTO_WEEKLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
@@ -636,12 +642,16 @@ fn propagate_to_ui() {
let weekly_text = entry
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at);
let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at);
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
session_text,
session_resets_at,
weekly_pct,
weekly_text,
weekly_resets_at,
);
}
refresh_tray_icons_with(&snap);
@@ -759,14 +769,7 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
} else {
None
},
tooltip: format!(
"{} {}: {} | {}: {}",
snap.i18n_strings.claude_label,
snap.i18n_strings.session_window,
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
snap.i18n_strings.weekly_window,
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
),
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
});
}
if snap.settings.show_codex {
@@ -778,19 +781,27 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
} else {
None
},
tooltip: format!(
"{} {}: {} | {}: {}",
snap.i18n_strings.chatgpt_label,
snap.i18n_strings.session_window,
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
snap.i18n_strings.weekly_window,
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
),
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
});
}
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
}
fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String {
let session = entry
.map(|e| e.primary_text.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("...");
let weekly = entry
.map(|e| e.secondary_text.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("...");
format!(
"{label}\n{}: {session}\n{}: {weekly}\n{}",
strings.session_window, strings.weekly_window, strings.tray_left_click
)
}
fn handle_tray_action(action: TrayAction) {
match action {
TrayAction::None => {}
@@ -926,6 +937,7 @@ struct ContextMenuSnapshot {
widget_visible: bool,
install_channel: InstallChannel,
update_status: UpdateStatus,
bubble_size_logical: i32,
}
fn show_context_menu(owner_hwnd: HWND) {
@@ -945,6 +957,7 @@ fn show_context_menu(owner_hwnd: HWND) {
widget_visible: s.settings.widget_visible,
install_channel: s.install_channel,
update_status: s.update_status,
bubble_size_logical: s.settings.bubble_size_logical,
},
None => return,
};
@@ -986,13 +999,21 @@ fn show_context_menu(owner_hwnd: HWND) {
models,
IDM_MODEL_CLAUDE,
&snap.strings.claude_label,
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.show_claude {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
models,
IDM_MODEL_CHATGPT,
&snap.strings.chatgpt_label,
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.show_chatgpt {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_submenu(menu, models, &snap.strings.models);
@@ -1005,7 +1026,11 @@ fn show_context_menu(owner_hwnd: HWND) {
settings_menu,
IDM_START_WITH_WINDOWS,
&snap.strings.start_with_windows,
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if is_startup_enabled() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
settings_menu,
@@ -1024,7 +1049,11 @@ fn show_context_menu(owner_hwnd: HWND) {
lang,
IDM_LANG_SYSTEM,
&snap.strings.system_default,
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.language_override.is_none() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
for (i, (code, name)) in snap.available.iter().enumerate() {
let id = IDM_LANG_BASE + i as u16;
@@ -1052,7 +1081,12 @@ fn show_context_menu(owner_hwnd: HWND) {
} else {
MENU_ITEM_FLAGS(0)
};
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
append_item(
settings_menu,
IDM_VERSION_ACTION,
&version_label,
version_flags,
);
let Ok(auto_update) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(auto_update) failed");
@@ -1088,11 +1122,58 @@ fn show_context_menu(owner_hwnd: HWND) {
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
append_submenu(menu, settings_menu, &snap.strings.settings);
let Ok(controls) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(controls) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
controls,
IDM_SIZE_SMALLER,
&snap.strings.size_smaller,
if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
controls,
IDM_SIZE_LARGER,
&snap.strings.size_larger,
if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
controls,
IDM_RESET_SIZE,
&snap.strings.reset_size,
if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
},
);
let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null());
append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED);
append_submenu(menu, controls, &snap.strings.controls);
append_item(
menu,
tray::IDM_TOGGLE_WIDGET,
&snap.strings.show_widget,
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.widget_visible {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
@@ -1116,7 +1197,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
let w = os::to_utf16_nul(label);
unsafe {
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
let _ = AppendMenuW(
menu,
MF_POPUP,
submenu.0 as usize,
PCWSTR::from_raw(w.as_ptr()),
);
}
}
@@ -1181,7 +1267,9 @@ fn toggle_model(model: ProviderId) {
ProviderId::Claude => settings.show_claude_code,
ProviderId::ChatGpt => settings.show_codex,
};
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
let existing = lock_state()
.as_ref()
.and_then(|s| s.bubbles.get(&model).copied());
match (want, existing) {
(true, None) => spawn_bubble(model, &settings, is_dark),
(false, Some(h)) => {
@@ -1244,6 +1332,34 @@ fn reset_positions() {
spawn_poll_thread();
}
fn resize_bubbles(delta: i32) {
let current = lock_state()
.as_ref()
.map(|s| s.settings.bubble_size_logical)
.unwrap_or(bubble::DEFAULT_BUBBLE_SIZE);
set_bubble_size(current + delta);
}
fn set_bubble_size(size_logical: i32) {
let (hwnds, snap) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE);
if new_size == s.settings.bubble_size_logical {
return;
}
s.settings.bubble_size_logical = new_size;
let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::<Vec<_>>();
(hwnds, s.settings.clone())
};
settings::save(&snap);
for hwnd in hwnds {
bubble::set_size_logical(hwnd, snap.bubble_size_logical);
}
}
fn set_language(_dummy: Option<()>) {
update_settings(|s| {
s.i18n.set_active(None);
+267 -58
View File
@@ -2,8 +2,8 @@
//
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
// The shape is a stadium (rounded-rect with corner_radius = height/2). The left
// half is the "head" — a stroked progress ring around the 5h percentage glyph.
// The right half is the "tail" — small "7d" label, thin progress bar, countdown.
// half is the "head" — usage and remaining-time rings around the 5h percentage
// glyph. The right half is the "tail" — weekly usage and remaining-time bars.
//
// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc)
// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB;
@@ -14,6 +14,7 @@
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, SystemTime};
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
use windows::core::PCWSTR;
@@ -32,7 +33,9 @@ use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
const TIMER_FULLSCREEN_CHECK: usize = 5;
const TIMER_PULSE: usize = 6;
const TIMER_TIME_PROGRESS: usize = 7;
const PULSE_INTERVAL_MS: u32 = 80;
const TIME_PROGRESS_INTERVAL_MS: u32 = 60_000;
use crate::usage::ProviderId;
// ---------- Public types & API ----------
@@ -44,7 +47,7 @@ use crate::usage::ProviderId;
pub const MIN_BUBBLE_SIZE: i32 = 140;
pub const MAX_BUBBLE_SIZE: i32 = 360;
pub const DEFAULT_BUBBLE_SIZE: i32 = 200;
const RESIZE_STEP: i32 = 20;
pub const RESIZE_STEP_LOGICAL: i32 = 20;
const SNAP_ZONE_LOGICAL: i32 = 12;
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
const CORNER_INSET_LOGICAL: i32 = 12;
@@ -52,6 +55,8 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
@@ -72,8 +77,10 @@ pub struct BubbleConfig {
pub position: Option<(i32, i32)>,
pub session_pct: Option<f64>,
pub session_text: String,
pub session_resets_at: Option<SystemTime>,
pub weekly_pct: Option<f64>,
pub weekly_text: String,
pub weekly_resets_at: Option<SystemTime>,
pub is_dark: bool,
}
@@ -82,6 +89,31 @@ fn bubble_height_logical(width_logical: i32) -> i32 {
((width_logical * den) / num).max(20)
}
#[derive(Clone, Copy)]
enum UsageWindowKind {
Primary,
Secondary,
}
fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 {
// Claude exposes 5h/7d directly. Codex exposes primary/secondary fields;
// the product maps those to the same short/long windows in the compact UI.
match (model, window) {
(ProviderId::Claude, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
(ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
}
}
fn remaining_fraction(resets_at: Option<SystemTime>, duration_secs: u64) -> Option<f32> {
let reset = resets_at?;
let remaining = reset
.duration_since(SystemTime::now())
.unwrap_or_else(|_| Duration::from_secs(0));
Some((remaining.as_secs_f64() / duration_secs as f64).clamp(0.0, 1.0) as f32)
}
/// Owner-supplied event callbacks. The bubble window proc is a leaf — it
/// doesn't know about `app`. The owner installs these once at startup so the
/// proc can dispatch UI events back without an upward `crate::app::` reach.
@@ -137,9 +169,7 @@ pub fn register_class() {
/// message-loop dispatch.
pub fn create(config: BubbleConfig) -> HWND {
register_class();
let initial_size_logical = config
.size_logical
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let initial_size_logical = config.size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let dpi_for_create = crate::os::dpi::for_system();
let width_px = scale_to_dpi(initial_size_logical, dpi_for_create);
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create);
@@ -205,14 +235,17 @@ pub fn create(config: BubbleConfig) -> HWND {
dpi,
session_pct: config.session_pct,
session_text: config.session_text,
session_resets_at: config.session_resets_at,
weekly_pct: config.weekly_pct,
weekly_text: config.weekly_text,
weekly_resets_at: config.weekly_resets_at,
is_dark: config.is_dark,
drag_start_pos: None,
hidden_by_fullscreen: false,
user_hidden: false,
pulse_phase: 0,
pulse_timer_armed: false,
time_progress_timer_armed: false,
},
);
@@ -240,6 +273,7 @@ pub fn destroy(hwnd: HWND) {
unsafe {
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
let _ = KillTimer(hwnd, TIMER_PULSE);
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
let _ = DestroyWindow(hwnd);
}
}
@@ -275,8 +309,10 @@ pub fn update_data(
hwnd: HWND,
session_pct: Option<f64>,
session_text: String,
session_resets_at: Option<SystemTime>,
weekly_pct: Option<f64>,
weekly_text: String,
weekly_resets_at: Option<SystemTime>,
) {
{
let mut bubbles = lock_bubbles();
@@ -285,10 +321,13 @@ pub fn update_data(
};
b.session_pct = session_pct;
b.session_text = session_text;
b.session_resets_at = session_resets_at;
b.weekly_pct = weekly_pct;
b.weekly_text = weekly_text;
b.weekly_resets_at = weekly_resets_at;
}
sync_pulse_timer(hwnd);
sync_time_progress_timer(hwnd);
render(hwnd);
}
@@ -322,6 +361,32 @@ fn sync_pulse_timer(hwnd: HWND) {
}
}
fn sync_time_progress_timer(hwnd: HWND) {
let (should_be_armed, currently_armed) = {
let bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
return;
};
(
b.session_resets_at.is_some() || b.weekly_resets_at.is_some(),
b.time_progress_timer_armed,
)
};
if should_be_armed == currently_armed {
return;
}
unsafe {
if should_be_armed {
SetTimer(hwnd, TIMER_TIME_PROGRESS, TIME_PROGRESS_INTERVAL_MS, None);
} else {
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
}
}
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
b.time_progress_timer_armed = should_be_armed;
}
}
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
{
let mut bubbles = lock_bubbles();
@@ -366,9 +431,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
}
pub fn model(hwnd: HWND) -> Option<ProviderId> {
lock_bubbles()
.get(&(hwnd.0 as isize))
.map(|b| b.model)
lock_bubbles().get(&(hwnd.0 as isize)).map(|b| b.model)
}
pub fn size_logical(hwnd: HWND) -> Option<i32> {
@@ -385,8 +448,10 @@ struct BubbleState {
dpi: u32,
session_pct: Option<f64>,
session_text: String,
session_resets_at: Option<SystemTime>,
weekly_pct: Option<f64>,
weekly_text: String,
weekly_resets_at: Option<SystemTime>,
is_dark: bool,
drag_start_pos: Option<(i32, i32)>,
hidden_by_fullscreen: bool,
@@ -396,6 +461,8 @@ struct BubbleState {
pulse_phase: u32,
/// Whether TIMER_PULSE is currently armed for this bubble.
pulse_timer_armed: bool,
/// Whether TIMER_TIME_PROGRESS is armed to keep reset-time visuals current.
time_progress_timer_armed: bool,
}
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
@@ -468,7 +535,11 @@ unsafe extern "system" fn wnd_proc(
const MK_CONTROL: u32 = 0x0008;
if modifiers & MK_CONTROL != 0 {
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP };
let step = if delta > 0 {
RESIZE_STEP_LOGICAL
} else {
-RESIZE_STEP_LOGICAL
};
resize_step(hwnd, step);
LRESULT(0)
} else {
@@ -505,6 +576,7 @@ unsafe extern "system" fn wnd_proc(
}
render(hwnd);
}
w if w == TIMER_TIME_PROGRESS => render(hwnd),
_ => {}
}
LRESULT(0)
@@ -583,12 +655,19 @@ fn lparam_to_point(lparam: LPARAM) -> POINT {
// ---------- Resize / snap ----------
fn resize_step(hwnd: HWND, delta: i32) {
let Some(current) = size_logical(hwnd) else {
return;
};
set_size_logical(hwnd, current + delta);
}
pub fn set_size_logical(hwnd: HWND, size_logical: i32) {
let (new_logical, dpi) = {
let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return;
};
let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let new_logical = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
if new_logical == b.size_logical {
return;
}
@@ -954,7 +1033,7 @@ const COUNTDOWN_TEMPLATE: &str = "999시간";
///
/// The outline is a stadium (rounded rect with `corner_radius = canvas_h / 2`).
/// The left `head_diameter × canvas_h` square holds the 5h progress ring + big
/// percent glyph. The rest is the tail: 7d label, thin bar, countdown.
/// percent glyph. The rest is the tail: weekly usage lane + reset-time lane.
struct BubbleLayout {
canvas_w: i32,
canvas_h: i32,
@@ -964,11 +1043,14 @@ struct BubbleLayout {
ring_cy: f32,
ring_radius: f32,
ring_stroke_w: f32,
time_ring_radius: f32,
time_ring_stroke_w: f32,
head_label_rect: RECT,
head_pct_rect: RECT,
tail_label_rect: RECT,
tail_bar_rect: RECT,
tail_countdown_rect: RECT,
tail_usage_pct_rect: RECT,
tail_usage_bar_rect: RECT,
tail_time_text_rect: RECT,
tail_time_bar_rect: RECT,
big_font_px: i32,
small_font_px: i32,
main_font_px: i32,
@@ -987,6 +1069,9 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
// inside the head padding. ring_radius is the centerline radius.
let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32);
let ring_radius = ring_outer - ring_stroke_w / 2.0;
let time_ring_stroke_w = scale_to_dpi(2, dpi).clamp(1, 3) as f32;
let time_ring_radius =
(ring_radius - ring_stroke_w - scale_to_dpi(3, dpi) as f32).max(time_ring_stroke_w);
let big_font_px = (head_diameter * 26 / 100).max(scale_to_dpi(11, dpi));
let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi));
@@ -1015,17 +1100,39 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
let pad = scale_to_dpi(6, dpi);
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px);
let label_w = measure_text_w(mem_dc, "7d", small_font_px);
let pct_reserve_w = measure_text_w(mem_dc, "100%", small_font_px) + scale_to_dpi(2, dpi);
let tail_label_left = tail_left + pad;
let tail_label_right = tail_label_left + label_w;
let tail_countdown_right = tail_right;
let tail_countdown_left = tail_countdown_right - countdown_w;
let tail_bar_left = tail_label_right + pad;
let tail_bar_right =
(tail_countdown_left - pad).max(tail_bar_left + scale_to_dpi(20, dpi));
let tail_bar_h = scale_to_dpi(5, dpi);
let tail_bar_top = (height_px - tail_bar_h) / 2;
let usage_bar_h = (height_px * 9 / 100).clamp(scale_to_dpi(5, dpi), scale_to_dpi(12, dpi));
let time_bar_h = (height_px * 5 / 100).clamp(scale_to_dpi(3, dpi), scale_to_dpi(7, dpi));
let lane_gap = scale_to_dpi(5, dpi);
let lanes_h = usage_bar_h + lane_gap + time_bar_h;
let usage_bar_top = (height_px - lanes_h) / 2;
let time_bar_top = usage_bar_top + usage_bar_h + lane_gap;
let time_text_h = main_font_px + scale_to_dpi(2, dpi);
let usage_pct_h = small_font_px + scale_to_dpi(2, dpi);
let content_left = tail_left + pad;
let content_right = tail_right;
let content_w = (content_right - content_left).max(0);
let time_text_w = countdown_w.min(content_w);
let time_text_left = content_right - time_text_w;
let time_bar_min = scale_to_dpi(8, dpi);
let time_bar_right = (time_text_left - pad).max(content_left + time_bar_min);
let time_bar_left = content_left.min(time_bar_right);
let usage_bar_min = scale_to_dpi(8, dpi);
let show_usage_pct = content_w >= pct_reserve_w + pad + usage_bar_min;
let usage_pct_right = if show_usage_pct {
content_left + pct_reserve_w
} else {
content_left
};
let usage_bar_left = if show_usage_pct {
usage_pct_right + pad
} else {
content_left
};
let usage_bar_right = content_right.max(usage_bar_left + usage_bar_min);
BubbleLayout {
canvas_w: width_px,
@@ -1036,25 +1143,33 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
ring_cy,
ring_radius,
ring_stroke_w,
time_ring_radius,
time_ring_stroke_w,
head_label_rect,
head_pct_rect,
tail_label_rect: RECT {
left: tail_label_left,
top: 0,
right: tail_label_right,
bottom: height_px,
tail_usage_pct_rect: RECT {
left: content_left,
top: usage_bar_top + (usage_bar_h - usage_pct_h) / 2,
right: usage_pct_right,
bottom: usage_bar_top + (usage_bar_h - usage_pct_h) / 2 + usage_pct_h,
},
tail_bar_rect: RECT {
left: tail_bar_left,
top: tail_bar_top,
right: tail_bar_right,
bottom: tail_bar_top + tail_bar_h,
tail_usage_bar_rect: RECT {
left: usage_bar_left,
top: usage_bar_top,
right: usage_bar_right,
bottom: usage_bar_top + usage_bar_h,
},
tail_countdown_rect: RECT {
left: tail_countdown_left,
top: 0,
right: tail_countdown_right,
bottom: height_px,
tail_time_text_rect: RECT {
left: time_text_left,
top: time_bar_top + (time_bar_h - time_text_h) / 2,
right: content_right,
bottom: time_bar_top + (time_bar_h - time_text_h) / 2 + time_text_h,
},
tail_time_bar_rect: RECT {
left: time_bar_left,
top: time_bar_top,
right: time_bar_right,
bottom: time_bar_top + time_bar_h,
},
big_font_px,
small_font_px,
@@ -1079,6 +1194,16 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
} else {
Color::from_hex("#D6D6D6")
};
let time_track = if inputs.is_dark {
Color::from_hex("#303030")
} else {
Color::from_hex("#E0E0E0")
};
let time_fill = if inputs.is_dark {
Color::from_hex("#9A9A9A")
} else {
Color::from_hex("#777777")
};
// ---- Stadium background ----
{
@@ -1119,7 +1244,8 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
if let Some(pct) = inputs.session_pct {
let sweep = (pct.clamp(0.0, 100.0) / 100.0) as f32;
if sweep > 0.0 {
let mut color = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct);
let mut color =
crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct);
if pct >= 95.0 {
let t = pulse_triangle(inputs.pulse_phase);
color = brighten(color, t);
@@ -1137,14 +1263,45 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
}
}
}
// Inner ring: true remaining time for the 5h/primary window. This
// stays neutral so it reads as time, not another quota alarm.
let mut paint = Paint::default();
paint.set_color(rgb_to_skia(time_track));
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = layout.time_ring_stroke_w;
let mut pb = PathBuilder::new();
pb.push_circle(layout.ring_cx, layout.ring_cy, layout.time_ring_radius);
if let Some(p) = pb.finish() {
pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None);
}
if let Some(frac) = remaining_fraction(
inputs.session_resets_at,
window_duration_secs(inputs.model, UsageWindowKind::Primary),
) {
if frac > 0.0 {
let mut paint = Paint::default();
paint.set_color(rgb_to_skia(time_fill));
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = layout.time_ring_stroke_w;
stroke.line_cap = LineCap::Round;
if let Some(path) =
build_arc(layout.ring_cx, layout.ring_cy, layout.time_ring_radius, frac)
{
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
}
}
// ---- Tail bar (7d) ----
// ---- Tail usage bar + reset-time bar ----
{
let bar_x = layout.tail_bar_rect.left as f32;
let bar_y = layout.tail_bar_rect.top as f32;
let bar_w = (layout.tail_bar_rect.right - layout.tail_bar_rect.left) as f32;
let bar_h = (layout.tail_bar_rect.bottom - layout.tail_bar_rect.top) as f32;
let bar_x = layout.tail_usage_bar_rect.left as f32;
let bar_y = layout.tail_usage_bar_rect.top as f32;
let bar_w = (layout.tail_usage_bar_rect.right - layout.tail_usage_bar_rect.left) as f32;
let bar_h = (layout.tail_usage_bar_rect.bottom - layout.tail_usage_bar_rect.top) as f32;
let cap = bar_h * 0.5;
if bar_w > 0.0 && bar_h > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, track);
@@ -1163,13 +1320,31 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
}
}
}
let bar_x = layout.tail_time_bar_rect.left as f32;
let bar_y = layout.tail_time_bar_rect.top as f32;
let bar_w = (layout.tail_time_bar_rect.right - layout.tail_time_bar_rect.left) as f32;
let bar_h = (layout.tail_time_bar_rect.bottom - layout.tail_time_bar_rect.top) as f32;
let cap = bar_h * 0.5;
if bar_w > 0.0 && bar_h > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, time_track);
if let Some(frac) = remaining_fraction(
inputs.weekly_resets_at,
window_duration_secs(inputs.model, UsageWindowKind::Secondary),
) {
let fill_w = bar_w * frac;
if fill_w > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, fill_w.min(bar_w), bar_h, cap, time_fill);
}
}
}
}
Some(pixmap)
}
/// Fill a horizontal pill at `(x, y, w, h)` with circular end caps of radius
/// `cap`. Used for both the track and the fill of the tail's 7d bar.
/// `cap`. Used for both track and fill segments in the tail bars.
fn paint_pill(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, cap: f32, color: Color) {
let mut paint = Paint::default();
paint.set_color(rgb_to_skia(color));
@@ -1273,8 +1448,10 @@ struct PaintInputs {
model: ProviderId,
session_pct: Option<f64>,
session_text: String,
session_resets_at: Option<SystemTime>,
weekly_pct: Option<f64>,
weekly_text: String,
weekly_resets_at: Option<SystemTime>,
is_dark: bool,
pulse_phase: u32,
}
@@ -1292,8 +1469,10 @@ fn render(hwnd: HWND) {
model: b.model,
session_pct: b.session_pct,
session_text: b.session_text.clone(),
session_resets_at: b.session_resets_at,
weekly_pct: b.weekly_pct,
weekly_text: b.weekly_text.clone(),
weekly_resets_at: b.weekly_resets_at,
is_dark: b.is_dark,
pulse_phase: b.pulse_phase,
},
@@ -1325,8 +1504,8 @@ fn render(hwnd: HWND) {
..Default::default()
};
let mut bits: *mut c_void = std::ptr::null_mut();
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.unwrap_or_default();
let dib =
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
if dib.is_invalid() || bits.is_null() {
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, screen_dc);
@@ -1413,8 +1592,8 @@ fn brighten(c: Color, t: f64) -> Color {
)
}
/// Paint the new bubble's text overlay via GDI: small "5h" label + big "%"
/// glyph in the head, small "7d" label + countdown on the tail.
/// Paint the bubble's text overlay via GDI: primary countdown + big "%"
/// glyph in the head, weekly percent + weekly countdown on the tail.
fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
let text_color = if inputs.is_dark {
Color::from_hex("#EAEAEA")
@@ -1436,9 +1615,24 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
let prev_font = SelectObject(hdc, small_font);
// Head: "5h" label (muted, centered horizontally).
// Head: 5h countdown text if available, otherwise the static "5h" tag.
// The ring already signals "this is the 5h window", so the countdown
// is the more useful glanceable info when we have it. Fall back to
// "5h" when the localized countdown would overflow the rect (e.g.,
// wide CJK strings like "999시간" at the 140-logical minimum width) —
// DT_NOCLIP would otherwise leak the glyphs onto the ring stroke.
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_text_in_rect(hdc, &layout.head_label_rect, "5h", DT_CENTER);
let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left;
let head_label_text: &str = if inputs.session_text.is_empty() {
"5h"
} else if measure_text_w(hdc, &inputs.session_text, layout.small_font_px)
<= head_label_rect_w
{
inputs.session_text.as_str()
} else {
"5h"
};
draw_text_in_rect(hdc, &layout.head_label_rect, head_label_text, DT_CENTER);
// Head: big "X%" glyph centered.
SelectObject(hdc, big_font);
@@ -1449,16 +1643,31 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
};
draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER);
// Tail: "7d" label (muted, left-aligned).
// Tail: weekly percent (foreground color, aligned with its usage bar). Skipped
// when the layout collapsed the rect at small widths. Foreground —
// not the accent color the bar uses — because Codex teal #10A37F on
// the light theme background only hits ~3.2:1 contrast, below WCAG
// AA for small text. Adjacency to the bar carries the visual
// grouping; we don't need hue to do it too.
SelectObject(hdc, small_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT);
if let Some(pct) = inputs.weekly_pct {
if layout.tail_usage_pct_rect.right > layout.tail_usage_pct_rect.left {
let mut color = text_color;
if pct >= 95.0 {
let t = pulse_triangle(inputs.pulse_phase);
color = brighten(color, t);
}
SetTextColor(hdc, COLORREF(color.into_colorref()));
let weekly_pct_text = format!("{:.0}%", pct);
draw_text_in_rect(hdc, &layout.tail_usage_pct_rect, &weekly_pct_text, DT_CENTER);
}
}
// Tail: countdown (right-aligned).
// Tail: weekly countdown aligned with its true remaining-time bar.
SelectObject(hdc, main_font);
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
if !inputs.weekly_text.is_empty() {
draw_text_in_rect(hdc, &layout.tail_countdown_rect, &inputs.weekly_text, DT_RIGHT);
draw_text_in_rect(hdc, &layout.tail_time_text_rect, &inputs.weekly_text, DT_RIGHT);
}
SelectObject(hdc, prev_font);
+10
View File
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
settings = "Settings"
start_with_windows = "Start with Windows"
reset_position = "Reset position"
size_smaller = "Make smaller"
size_larger = "Make larger"
reset_size = "Reset size"
controls = "Controls"
control_left_click = "Left-click: details"
control_right_click = "Right-click: menu"
control_drag = "Drag: move/snap"
control_ctrl_wheel = "Ctrl+Wheel: resize"
control_tray_click = "Tray click: show/hide"
tray_left_click = "Left-click: show/hide"
language = "Language"
system_default = "System default"
check_for_updates = "Check for updates"
+10
View File
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "Windows起動時に開始"
reset_position = "位置をリセット"
size_smaller = "小さくする"
size_larger = "大きくする"
reset_size = "サイズをリセット"
controls = "操作"
control_left_click = "左クリック: 詳細"
control_right_click = "右クリック: メニュー"
control_drag = "ドラッグ: 移動/吸着"
control_ctrl_wheel = "Ctrl+ホイール: サイズ変更"
control_tray_click = "トレイ左クリック: 表示/非表示"
tray_left_click = "左クリック: 表示/非表示"
language = "言語"
system_default = "システム既定"
check_for_updates = "更新を確認"
+10
View File
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
settings = "설정"
start_with_windows = "Windows 시작 시 실행"
reset_position = "위치 초기화"
size_smaller = "작게"
size_larger = "크게"
reset_size = "크기 초기화"
controls = "조작"
control_left_click = "왼쪽 클릭: 상세"
control_right_click = "오른쪽 클릭: 메뉴"
control_drag = "드래그: 이동/스냅"
control_ctrl_wheel = "Ctrl+휠: 크기 조절"
control_tray_click = "트레이 클릭: 표시/숨김"
tray_left_click = "왼쪽 클릭: 표시/숨김"
language = "언어"
system_default = "시스템 기본값"
check_for_updates = "업데이트 확인"
+10
View File
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
settings = "Cài đặt"
start_with_windows = "Khởi động cùng Windows"
reset_position = "Đặt lại vị trí"
size_smaller = "Thu nhỏ"
size_larger = "Phóng to"
reset_size = "Đặt lại kích thước"
controls = "Điều khiển"
control_left_click = "Nhấp trái: chi tiết"
control_right_click = "Nhấp phải: menu"
control_drag = "Kéo: di chuyển/bám"
control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ"
control_tray_click = "Khay: hiện/ẩn"
tray_left_click = "Nhấp trái: hiện/ẩn"
language = "Ngôn ngữ"
system_default = "Mặc định hệ thống"
check_for_updates = "Kiểm tra cập nhật"
+10
View File
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
settings = "設定"
start_with_windows = "隨 Windows 啟動"
reset_position = "重設位置"
size_smaller = "縮小"
size_larger = "放大"
reset_size = "重設大小"
controls = "操作"
control_left_click = "左鍵: 詳細"
control_right_click = "右鍵: 選單"
control_drag = "拖曳: 移動/吸附"
control_ctrl_wheel = "Ctrl+滾輪: 調整大小"
control_tray_click = "系統匣: 顯示/隱藏"
tray_left_click = "左鍵: 顯示/隱藏"
language = "語言"
system_default = "系統預設"
check_for_updates = "檢查更新"
+81 -5
View File
@@ -34,6 +34,16 @@ pub struct LocaleStrings {
pub settings: String,
pub start_with_windows: String,
pub reset_position: String,
pub size_smaller: String,
pub size_larger: String,
pub reset_size: String,
pub controls: String,
pub control_left_click: String,
pub control_right_click: String,
pub control_drag: String,
pub control_ctrl_wheel: String,
pub control_tray_click: String,
pub tray_left_click: String,
pub language: String,
pub system_default: String,
pub check_for_updates: String,
@@ -158,9 +168,7 @@ impl I18n {
pub fn set_active(&mut self, requested: Option<&str>) {
let new_active = requested
.and_then(|c| normalise(c, &self.available))
.or_else(|| {
detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))
})
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)))
.unwrap_or_else(|| FALLBACK_CODE.to_string());
self.active = new_active;
}
@@ -183,7 +191,9 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
}
// Special-case: Traditional Chinese variants → zh-TW
let lower = cleaned.to_ascii_lowercase();
if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) {
if lower.starts_with("zh")
&& (lower.contains("tw") || lower.contains("hk") || lower.contains("hant"))
{
if available.contains_key("zh-TW") {
return Some("zh-TW".to_string());
}
@@ -192,7 +202,13 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
let prefix = lower.split('-').next().unwrap_or("");
if !prefix.is_empty() {
for key in available.keys() {
if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) {
if key
.split('-')
.next()
.map(str::to_ascii_lowercase)
.as_deref()
== Some(prefix)
{
return Some(key.clone());
}
}
@@ -259,3 +275,63 @@ pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Durati
};
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_locales_parse_and_include_required_control_strings() {
let mut has_fallback = false;
for (expected_code, body) in RAW_LOCALES {
let file = toml::from_str::<LocaleFile>(body)
.unwrap_or_else(|e| panic!("locale {expected_code} failed to parse: {e}"));
assert_eq!(file.code, *expected_code);
has_fallback |= file.code == FALLBACK_CODE;
let strings = file.strings;
for (name, value) in [
("size_smaller", strings.size_smaller.as_str()),
("size_larger", strings.size_larger.as_str()),
("reset_size", strings.reset_size.as_str()),
("controls", strings.controls.as_str()),
("control_left_click", strings.control_left_click.as_str()),
("control_right_click", strings.control_right_click.as_str()),
("control_drag", strings.control_drag.as_str()),
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
("control_tray_click", strings.control_tray_click.as_str()),
("tray_left_click", strings.tray_left_click.as_str()),
] {
assert!(
!value.trim().is_empty(),
"locale {expected_code} has empty {name}"
);
}
}
assert!(has_fallback, "fallback locale {FALLBACK_CODE} missing");
}
#[test]
fn locale_schema_rejects_missing_or_malformed_control_strings() {
let (_, fallback_body) = RAW_LOCALES
.iter()
.find(|(code, _)| *code == FALLBACK_CODE)
.expect("fallback locale fixture must exist");
let missing_control =
fallback_body.replace("tray_left_click = \"Left-click: show/hide\"\n", "");
assert!(
toml::from_str::<LocaleFile>(&missing_control).is_err(),
"missing tray_left_click should fail locale deserialization"
);
let malformed_control = fallback_body.replace(
"control_tray_click = \"Tray click: show/hide\"",
"control_tray_click = [\"Tray click: show/hide\"]",
);
assert!(
toml::from_str::<LocaleFile>(&malformed_control).is_err(),
"malformed control_tray_click should fail locale deserialization"
);
}
}