Compare commits

..
18 Commits
Author SHA1 Message Date
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
tiennm99 391ad0cba2 chore: bump version to 0.1.15 2026-05-23 12:54:46 +07:00
tiennm99 77325b1e00 fix(bubble): restore alpha after GDI text so glyphs aren't transparent
User report on v0.1.14: text appears semi-transparent, desktop wallpaper
bleeds through glyph pixels.

Root cause: GDI's DrawTextW writes only RGB into 32bpp BI_RGB DIBs — the
"reserved" alpha byte (byte 3) is not preserved per the BITMAPINFOHEADER
contract. When UpdateLayeredWindow later composites with AC_SRC_ALPHA, it
reads alpha=0 at every glyph pixel and shows them as fully transparent.

The pre-v0.1.13 pipeline worked around this with an apply_alpha_mask
post-pass that OR'd 0xFF000000 into every pixel inside the rounded rect.
The stadium-shape rewrite (526786b) removed it on the false assumption
that tiny-skia's per-pixel alpha would "stick" through subsequent GDI
writes — but GDI runs *after* tiny-skia in the pipeline, so any pixel
GDI text writes to loses the alpha that tiny-skia set.

Fix: re-stamp the alpha channel from the original Pixmap after the GDI
text overlay. This restores tiny-skia's exact alpha values (255 in the
stadium interior, partial on the AA curved perimeter, 0 outside),
including the AA fade at the stadium's rounded ends.

Implementation:
- new helper `restore_alpha_from_pixmap(pixmap, dst)` next to the
  existing `copy_pixmap_to_dib`
- hoist `pixmap` out of the if-let arm in render() so it survives until
  after `paint_bubble_text`
- call `restore_alpha_from_pixmap` post-text

Two parallel reviewers (debugger + code-reviewer) converged on the same
diagnosis; the debugger preferred this approach for its simplicity and
because it's robust to any GDI behavior (whether alpha is zeroed,
untouched, or scribbled on, we overwrite with the known-good value).

Build clean.
2026-05-23 12:54:13 +07:00
tiennm99 c3d01f36d2 chore: bump version to 0.1.14 2026-05-23 12:30:52 +07:00
tiennm99 7bbf80e5f7 fix(bubble): tune head proportions — smaller percent glyph, more breathing room
User feedback on v0.1.13: design works, but the 5h percent glyph in the
head crowds the ring at small bubble sizes (the "100%" string was wider
than the ring's inner clear at MIN_BUBBLE_SIZE).

Two parallel UI/UX reviewers converged on:

- big_font_px ratio:   head_diameter × 26/100 (was 35/100), floor 11
- small_font_px ratio: big × 55/100         (was 45/100), floor 9
- head_pad:            4 logical px         (was 6) — recovers 4px
                       of inner clear at small sizes
- ring_stroke_w:       clamped to [2, 4]    (was floor 2 only)
- label/glyph gap:     big × 15/100, floor 2 (was implicit 0)
- tail_bar_h:          5 logical px         (was 6) — restores
                       proportion against the 3-px head ring stroke

Worked example at MIN_BUBBLE_SIZE=140 (head_diameter=47):
  before: "100%" glyph ≈ 32px wide vs 28px ring inner — overflow
  after:  "100%" glyph ≈ 23px wide vs 32px ring inner — comfortable

Worked example at MAX_BUBBLE_SIZE=360 (head_diameter=138):
  glyph ≈ 70px wide in 124px inner clear (~57%) — confident not crowding

Deliberately not applying:
- drop "7d" label (one reviewer wanted it): rejected — symmetry with
  "5h" matters for self-explanation at a glance, and the ~14px cost is
  acceptable
- head_diameter bump to canvas_h × 1.08: rejected — only useful coupled
  with the label drop

Build clean.
2026-05-23 12:30:18 +07:00
tiennm99 8cbc3dda5b chore: bump version to 0.1.13 2026-05-23 12:09:36 +07:00
tiennm99 526786b902 feat(bubble): new stadium shape with ring head + tail bar
Phase 2 lite. Replaces the horizontal pill (two stacked progress bars)
with a stadium-shaped bubble: a circle "head" on the left showing the
5h percentage as a big glyph surrounded by a stroked progress ring,
plus a "tail" extending right with the 7d label, a thin progress bar,
and the 7d countdown.

The bubble's primary metric (5h window) is now glanceable from across
the room — a thick ring sweeping around a big number reads at a much
greater distance than two thin horizontal bars. The 7d window remains
visible as supporting context. The expanded panel (left-click) still
shows both windows in full.

Implementation notes:
- Hybrid render: tiny-skia (already a Cargo dep for tray badge) paints
  the AA shape into a Pixmap. The pixmap is copied byte-for-byte into
  the 32bpp BI_RGB DIB; GDI overlays ClearType text on top;
  UpdateLayeredWindow blits with per-pixel alpha as before.
- Stadium outline: corner_radius = height/2 so point_in_rounded_rect
  exactly approximates the capsule shape for hit-test.
- Pulse animation on ≥95% applies to both the ring sweep (5h) and the
  tail bar fill (7d) independently.
- Codex teal #10A37F and Claude orange #D97757 carry across the ring,
  the tail bar, and the tray badge sweep via crate::usage_color.

Removed (dead after pipeline swap):
- per-pixel paint_background / paint_accent_stripe / paint_bars /
  paint_one_bar / apply_alpha_mask / row_band / rgb_to_dib / blend
- BarLayout struct + compute_layout
- old paint_text_layer / draw_label / draw_percent / draw_countdown
- Breakpoint struct + breakpoint_for_width_logical (font sizes now
  derive from head_diameter directly)
- luminance / use_dark_text_over (text was over bar fills; new tail
  bar carries no overlaid text)
- constants ACCENT_STRIPE_W_LOGICAL, LABEL_PAD_LOGICAL,
  PERCENT_TEMPLATE

Build: cargo build --release clean. Clippy 13 warnings (was 11); the
2 new ones are field-assign-after-Default::default() on tiny-skia
Stroke setup, matching the existing pattern in src/tray/badge.rs.

Known follow-up: BubbleState.session_text + BubbleConfig.session_text
plumbing is now unused (head shows percent only, no 5h countdown on
the bubble). Removing it is a multi-file chain through app.rs and
panel.rs; deferred.
2026-05-23 12:09:03 +07:00
tiennm99 f96d88074a chore: bump version to 0.1.12 2026-05-23 11:23:14 +07:00
tiennm99 e243c589a8 refactor: fix latent bugs, invert bubble→app deps, unify color path
Whole-project review pass over the entire crate. No new features.
All function definitions preserved; layout and visibility reorganised.

Bug fixes:
- bubble: ExtractIconExW HICON pair was leaked per bubble toggle.
  Extract once into a process-wide OnceLock, reuse forever (bounded).
- usage/anthropic: parse_iso8601 was stripping the UTC offset without
  applying it — negative-offset users saw countdowns up to 14h wrong.
  Now parses signed minutes and computes utc_secs = local - off*60.
  Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so
  malformed API responses can't index DAYS_IN_MONTH out of bounds.
- usage: clamp utilization to [0,100] at all four Window construction
  sites so a misbehaving server can't render "121%".
- bubble: GetDC and CreateCompatibleDC results weren't checked. Guard
  both; release the screen DC on the CreateCompatibleDC failure path.

Refactor:
- Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId
  directly everywhere. Inline the identity-function kind_to_provider.
- Delete panel::bar_color_for shim (was just argument-reorder glue).
- Replace local scale_to_dpi fns in bubble.rs and panel.rs with
  use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the
  live import graph; was unused before).
- Delete dead PCWSTR import + #[allow(dead_code)] sentinel in
  tray/badge.rs; fold the trailing `use BOOL` into the top imports.
- Inline app::primary_dpi() to crate::os::dpi::for_system().

app.rs:
- Add update_settings(|s: &mut AppState|) helper that locks state,
  runs the closure, snapshots Settings, drops the lock, then saves
  to disk. Convert four pure-mutate-then-save callsites.

Layering: bubble.rs no longer reaches upward into crate::app::.
Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and
bubble::install_callbacks(). The wnd_proc dispatches the six prior
upward calls via a private dispatch() helper. app::run installs
callbacks once at startup; the six on_bubble_* / recheck_theme fns
are demoted from pub fn to fn.

Resource-warning logs added: dispatch() warns on uninstalled
callbacks; app_icons() warns when ExtractIconExW returns nulls.

Build: cargo build --release clean; cargo clippy reports zero new
warnings (11 pre-existing, all in untouched code).
2026-05-23 11:22:40 +07:00
tiennm99 a5dec52aaa feat(ui): unify usage colors, fix per-bar coloring, round panel corners
Phase 0 of UI/UX polish pass. Surgical changes, no substrate migration yet.

- Extract bar_fill_color + accent_color_for into new src/usage_color.rs so
  the bubble, panel, and tray badge agree on a single 4-band usage ramp.
- Panel: color each bar from its own percent (was using max(5h, 7d) for
  both rows, so a healthy 5h bar turned red whenever 7d was full).
- Light-mode amber #B47A20 (was #E0A040, failed WCAG AA at 2.4:1).
- Codex identity: switch from white/charcoal to OpenAI teal #10A37F
  across bubble, panel stripe, and tray sweep so the surfaces share one
  brand color and the tray badge stops reading as "loading spinner".
- Panel: drop WS_BORDER, add DwmSetWindowAttribute(DWMWCP_ROUND) for
  Win11 rounded corners. Idempotent re-apply on every show() so the
  attribute survives any future destroy/recreate path. Silently no-ops
  on Win10.
2026-05-23 09:18:42 +07:00
tiennm99 4e0f32591b chore: bump version to 0.1.11 2026-05-21 16:51:46 +07:00
tiennm99 3e1af07ec2 feat(i18n): switch supported languages to en/ja/ko/vi/zh-TW
Drop nl/es/fr/de locales (no native-speaker maintenance) and add
Vietnamese. The supported set is now the languages with active
users we can support: English, Japanese, Korean, Vietnamese, and
Traditional Chinese.
2026-05-21 16:51:15 +07:00
24 changed files with 1119 additions and 822 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.10"
version = "0.3.2"
dependencies = [
"dirs",
"embed-resource",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.10"
version = "0.3.2"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
@@ -23,6 +23,7 @@ version = "0.58"
features = [
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_UI_Shell",
+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?
+181 -87
View File
@@ -27,9 +27,7 @@ use crate::net;
use crate::os;
use crate::panel::{self, PanelData};
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData};
use crate::usage::ProviderId as TrayIconKind;
use crate::tray::WM_APP_TRAY;
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
use crate::usage::{self, ProviderId, Registry, UsageWindows};
@@ -72,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).
@@ -110,7 +111,7 @@ enum UpdateStatus {
struct AppState {
msg_hwnd: SendHwnd,
bubbles: HashMap<TrayIconKind, SendHwnd>,
bubbles: HashMap<ProviderId, SendHwnd>,
settings: Settings,
i18n: I18n,
is_dark: bool,
@@ -145,6 +146,21 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
state().lock().expect("app state mutex poisoned")
}
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
/// persist it to disk. The lock is released before the disk write so the UI
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
fn update_settings(f: impl FnOnce(&mut AppState)) {
let snap = {
let mut guard = lock_state();
let Some(s) = guard.as_mut() else {
return;
};
f(s);
s.settings.clone()
};
settings::save(&snap);
}
// ---------- Entry ----------
/// Acquire the singleton mutex, optionally retrying for ~3s if the
@@ -230,6 +246,15 @@ pub fn run(args: crate::AppArgs) {
last_balloon_at: None,
});
bubble::install_callbacks(bubble::Callbacks {
on_click: on_bubble_click,
on_right_click: on_bubble_right_click,
on_moved: on_bubble_moved,
on_resized: on_bubble_resized,
on_menu_command,
on_settings_changed: recheck_theme,
});
create_initial_bubbles();
refresh_tray_icons();
@@ -307,7 +332,7 @@ fn create_initial_bubbles() {
}
}
fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
// "…" matches the in-flight/transient-error placeholder used by
// `apply_results`, so the bubble has visible feedback during the first
// poll rather than rendering with two empty grey tracks.
@@ -365,40 +390,24 @@ unsafe extern "system" fn msg_wnd_proc(
// ---------- Bubble callbacks ----------
pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) {
fn on_bubble_click(hwnd: HWND, model: ProviderId) {
let data = build_panel_data(model);
panel::toggle(data, hwnd);
}
pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) {
fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) {
show_context_menu(hwnd);
}
pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) {
let snap = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.bubble_positions.set(model, pos);
s.settings.clone()
};
settings::save(&snap);
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
update_settings(|s| s.settings.bubble_positions.set(model, pos));
}
pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) {
let snap = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.bubble_size_logical = size_logical;
s.settings.clone()
};
settings::save(&snap);
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
set_bubble_size(size_logical);
}
pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
let id = (id & 0xFFFF) as u16;
match id {
IDM_REFRESH => spawn_poll_thread(),
@@ -412,13 +421,14 @@ pub 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))
}
@@ -618,8 +628,7 @@ fn propagate_to_ui() {
};
for (kind, hwnd) in snap.bubbles.iter() {
let id = kind_to_provider(*kind);
let entry = snap.snapshots.get(&id);
let entry = snap.snapshots.get(kind);
let session_pct = entry.map(|s| s.windows.primary.utilization);
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
// The bubble paints the percent inline inside the bar fill, so it
@@ -643,8 +652,7 @@ fn propagate_to_ui() {
if panel::is_visible() {
if let Some(model) = panel::current_model() {
let id = kind_to_provider(model);
if let Some(provider_state) = snap.snapshots.get(&id) {
if let Some(provider_state) = snap.snapshots.get(&model) {
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
}
}
@@ -654,7 +662,7 @@ fn propagate_to_ui() {
#[derive(Clone)]
struct UiSnapshot {
bubbles: HashMap<TrayIconKind, SendHwnd>,
bubbles: HashMap<ProviderId, SendHwnd>,
snapshots: HashMap<ProviderId, ProviderUiState>,
settings: Settings,
i18n_strings: LocaleStrings,
@@ -663,21 +671,13 @@ struct UiSnapshot {
last_poll_ok: bool,
}
fn kind_to_provider(k: TrayIconKind) -> ProviderId {
match k {
ProviderId::Claude => ProviderId::Claude,
ProviderId::ChatGpt => ProviderId::ChatGpt,
}
}
fn build_panel_data(model: TrayIconKind) -> PanelData {
fn build_panel_data(model: ProviderId) -> PanelData {
let s = lock_state();
let Some(s) = s.as_ref() else {
return placeholder_panel(model);
};
let id = kind_to_provider(model);
let strings = s.i18n.strings().clone();
let provider_state = s.snapshots.get(&id).cloned().unwrap_or_default();
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
PanelData {
model,
session_pct: provider_state.windows.primary.utilization,
@@ -689,7 +689,7 @@ fn build_panel_data(model: TrayIconKind) -> PanelData {
}
}
fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiState) -> PanelData {
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
PanelData {
model,
session_pct: p.windows.primary.utilization,
@@ -701,7 +701,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiS
}
}
fn placeholder_panel(model: TrayIconKind) -> PanelData {
fn placeholder_panel(model: ProviderId) -> PanelData {
let strings = i18n::I18n::load(None).strings().clone();
PanelData {
model,
@@ -763,14 +763,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 {
@@ -782,19 +775,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 => {}
@@ -816,7 +817,7 @@ fn handle_tray_action(action: TrayAction) {
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
/// posts that to every top-level window when the user toggles light/dark
/// in Settings, so this naturally fires once per change.
pub fn recheck_theme() {
fn recheck_theme() {
let now_dark = os::theme::is_dark();
let changed = {
let mut s = lock_state();
@@ -930,6 +931,7 @@ struct ContextMenuSnapshot {
widget_visible: bool,
install_channel: InstallChannel,
update_status: UpdateStatus,
bubble_size_logical: i32,
}
fn show_context_menu(owner_hwnd: HWND) {
@@ -949,6 +951,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,
};
@@ -990,13 +993,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);
@@ -1009,7 +1020,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,
@@ -1028,7 +1043,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;
@@ -1056,7 +1075,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");
@@ -1092,11 +1116,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));
@@ -1120,7 +1191,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()),
);
}
}
@@ -1161,7 +1237,7 @@ fn set_poll_interval(ms: u32) {
}
}
fn toggle_model(model: TrayIconKind) {
fn toggle_model(model: ProviderId) {
let (settings, is_dark) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
@@ -1185,7 +1261,9 @@ fn toggle_model(model: TrayIconKind) {
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)) => {
@@ -1248,34 +1326,50 @@ fn reset_positions() {
spawn_poll_thread();
}
fn set_language(_dummy: Option<()>) {
let snap = {
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;
};
s.i18n.set_active(None);
s.settings.language = None;
s.settings.clone()
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);
s.settings.language = None;
});
propagate_to_ui();
}
fn set_language_by_index(idx: usize) {
let snap = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
update_settings(|s| {
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
if let Some(c) = code.as_deref() {
s.i18n.set_active(Some(c));
}
s.settings.language = code;
s.settings.clone()
};
settings::save(&snap);
});
propagate_to_ui();
}
+517 -438
View File
File diff suppressed because it is too large Load Diff
-49
View File
@@ -1,49 +0,0 @@
code = "de"
native_name = "Deutsch"
window_title = "Claude Code Usage Bubble"
refresh = "Aktualisieren"
update_frequency = "Aktualisierungsintervall"
one_minute = "1 Minute"
five_minutes = "5 Minuten"
fifteen_minutes = "15 Minuten"
one_hour = "1 Stunde"
models = "Modelle"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Einstellungen"
start_with_windows = "Mit Windows starten"
reset_position = "Position zurücksetzen"
language = "Sprache"
system_default = "Systemstandard"
check_for_updates = "Nach Updates suchen"
checking_for_updates = "Suche läuft…"
up_to_date = "Aktuell"
update_failed = "Update fehlgeschlagen"
applying_update = "Update wird angewendet…"
update_available = "Update verfügbar"
update_via_winget = "über WinGet"
auto_update_check = "Automatische Updateprüfung"
auto_check_disabled = "Deaktiviert"
auto_check_hourly = "Stündlich"
auto_check_daily = "Täglich"
auto_check_weekly = "Wöchentlich"
exit = "Beenden"
restart = "Neu starten"
show_widget = "Widget anzeigen"
session_window = "5h"
weekly_window = "7d"
now = "jetzt"
day_suffix = "T"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code-Sitzung abgelaufen"
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
threshold_80_body = "5-Stunden-Limit naht."
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
update_applied_title = "Update angewendet"
update_applied_body = "Aktualisiert auf v"
update_rollback_failed_body = "Update fehlgeschlagen. Deine ursprüngliche Binärdatei liegt unter: "
+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"
-49
View File
@@ -1,49 +0,0 @@
code = "es"
native_name = "Español"
window_title = "Claude Code Usage Bubble"
refresh = "Actualizar"
update_frequency = "Frecuencia de actualización"
one_minute = "1 minuto"
five_minutes = "5 minutos"
fifteen_minutes = "15 minutos"
one_hour = "1 hora"
models = "Modelos"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Ajustes"
start_with_windows = "Iniciar con Windows"
reset_position = "Restablecer posición"
language = "Idioma"
system_default = "Predeterminado del sistema"
check_for_updates = "Buscar actualizaciones"
checking_for_updates = "Buscando actualizaciones…"
up_to_date = "Al día"
update_failed = "Actualización fallida"
applying_update = "Aplicando actualización…"
update_available = "Actualización disponible"
update_via_winget = "vía WinGet"
auto_update_check = "Búsqueda automática de actualizaciones"
auto_check_disabled = "Desactivada"
auto_check_hourly = "Cada hora"
auto_check_daily = "Cada día"
auto_check_weekly = "Cada semana"
exit = "Salir"
restart = "Reiniciar"
show_widget = "Mostrar widget"
session_window = "5h"
weekly_window = "7d"
now = "ahora"
day_suffix = "d"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Sesión de Claude Code caducada"
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
chatgpt_token_expired_title = "Sesión de Codex caducada"
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
threshold_80_body = "Cerca del límite de 5 horas."
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
update_applied_title = "Actualización aplicada"
update_applied_body = "Actualizado a v"
update_rollback_failed_body = "Actualización fallida. Tu binario original está guardado en: "
-49
View File
@@ -1,49 +0,0 @@
code = "fr"
native_name = "Français"
window_title = "Claude Code Usage Bubble"
refresh = "Actualiser"
update_frequency = "Fréquence de mise à jour"
one_minute = "1 minute"
five_minutes = "5 minutes"
fifteen_minutes = "15 minutes"
one_hour = "1 heure"
models = "Modèles"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Paramètres"
start_with_windows = "Lancer avec Windows"
reset_position = "Réinitialiser la position"
language = "Langue"
system_default = "Paramètre système"
check_for_updates = "Rechercher des mises à jour"
checking_for_updates = "Recherche en cours…"
up_to_date = "À jour"
update_failed = "Mise à jour échouée"
applying_update = "Mise à jour en cours…"
update_available = "Mise à jour disponible"
update_via_winget = "via WinGet"
auto_update_check = "Vérification automatique des mises à jour"
auto_check_disabled = "Désactivée"
auto_check_hourly = "Toutes les heures"
auto_check_daily = "Quotidienne"
auto_check_weekly = "Hebdomadaire"
exit = "Quitter"
restart = "Redémarrer"
show_widget = "Afficher le widget"
session_window = "5h"
weekly_window = "7j"
now = "maintenant"
day_suffix = "j"
hour_suffix = "h"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Session Claude Code expirée"
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
chatgpt_token_expired_title = "Session Codex expirée"
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
threshold_80_body = "Approche de la limite de 5 heures."
threshold_95_body = "Limite proche — pensez à lever le pied."
update_applied_title = "Mise à jour appliquée"
update_applied_body = "Mis à jour vers v"
update_rollback_failed_body = "Échec de la mise à jour. Votre binaire d'origine est enregistré à : "
+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 = "업데이트 확인"
-49
View File
@@ -1,49 +0,0 @@
code = "nl"
native_name = "Nederlands"
window_title = "Claude Code Usage Bubble"
refresh = "Vernieuwen"
update_frequency = "Bijwerkfrequentie"
one_minute = "1 minuut"
five_minutes = "5 minuten"
fifteen_minutes = "15 minuten"
one_hour = "1 uur"
models = "Modellen"
claude_label = "Claude Code"
chatgpt_label = "Codex"
settings = "Instellingen"
start_with_windows = "Starten met Windows"
reset_position = "Positie herstellen"
language = "Taal"
system_default = "Systeemstandaard"
check_for_updates = "Controleren op updates"
checking_for_updates = "Bezig met controleren…"
up_to_date = "Up-to-date"
update_failed = "Update mislukt"
applying_update = "Update toepassen…"
update_available = "Update beschikbaar"
update_via_winget = "via WinGet"
auto_update_check = "Automatische updatecontrole"
auto_check_disabled = "Uitgeschakeld"
auto_check_hourly = "Per uur"
auto_check_daily = "Dagelijks"
auto_check_weekly = "Wekelijks"
exit = "Afsluiten"
restart = "Opnieuw starten"
show_widget = "Widget tonen"
session_window = "5u"
weekly_window = "7d"
now = "nu"
day_suffix = "d"
hour_suffix = "u"
minute_suffix = "m"
second_suffix = "s"
token_expired_title = "Claude Code-sessie verlopen"
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
chatgpt_token_expired_title = "Codex-sessie verlopen"
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
threshold_80_body = "Je nadert de 5-uurslimiet."
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
update_applied_title = "Update toegepast"
update_applied_body = "Bijgewerkt naar v"
update_rollback_failed_body = "Update mislukt. Je oorspronkelijke bestand staat op: "
+59
View File
@@ -0,0 +1,59 @@
code = "vi"
native_name = "Tiếng Việt"
window_title = "Claude Code Usage Bubble"
refresh = "Làm mới"
update_frequency = "Tần suất cập nhật"
one_minute = "1 phút"
five_minutes = "5 phút"
fifteen_minutes = "15 phút"
one_hour = "1 giờ"
models = "Mô hình"
claude_label = "Claude Code"
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"
checking_for_updates = "Đang kiểm tra cập nhật…"
up_to_date = "Đã là phiên bản mới nhất"
update_failed = "Cập nhật thất bại"
applying_update = "Đang áp dụng cập nhật…"
update_available = "Có bản cập nhật mới"
update_via_winget = "qua WinGet"
auto_update_check = "Tự động kiểm tra cập nhật"
auto_check_disabled = "Tắt"
auto_check_hourly = "Mỗi giờ"
auto_check_daily = "Hằng ngày"
auto_check_weekly = "Hằng tuần"
exit = "Thoát"
restart = "Khởi động lại"
show_widget = "Hiện widget"
session_window = "5g"
weekly_window = "7n"
now = "ngay"
day_suffix = "n"
hour_suffix = "g"
minute_suffix = "p"
second_suffix = "s"
token_expired_title = "Phiên Claude Code đã hết hạn"
token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
chatgpt_token_expired_title = "Phiên Codex đã hết hạn"
chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
threshold_80_body = "Sắp chạm giới hạn 5 giờ."
threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc."
update_applied_title = "Đã áp dụng cập nhật"
update_applied_body = "Đã cập nhật lên v"
update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: "
+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 = "檢查更新"
+82 -9
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,
@@ -87,12 +97,9 @@ struct LocaleFile {
const RAW_LOCALES: &[(&str, &str)] = &[
("en", include_str!("locales/en.toml")),
("nl", include_str!("locales/nl.toml")),
("es", include_str!("locales/es.toml")),
("fr", include_str!("locales/fr.toml")),
("de", include_str!("locales/de.toml")),
("ja", include_str!("locales/ja.toml")),
("ko", include_str!("locales/ko.toml")),
("vi", include_str!("locales/vi.toml")),
("zh-TW", include_str!("locales/zh-TW.toml")),
];
@@ -161,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;
}
@@ -186,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());
}
@@ -195,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());
}
}
@@ -262,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"
);
}
}
+1
View File
@@ -13,6 +13,7 @@ mod os;
mod tray;
mod update;
mod usage;
mod usage_color;
// Application surface.
mod app;
+33 -23
View File
@@ -7,15 +7,18 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
};
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::HiDpi::GetDpiForWindow;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::i18n::LocaleStrings;
use crate::os::dpi::scale as scale_to_dpi;
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
use crate::usage::ProviderId;
type TrayIconKind = ProviderId;
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
const PANEL_W_LOGICAL: i32 = 280;
@@ -27,7 +30,7 @@ const RIGHT_TEXT_W_LOGICAL: i32 = 96;
const BAR_HEIGHT_LOGICAL: i32 = 14;
pub struct PanelData {
pub model: TrayIconKind,
pub model: ProviderId,
pub session_pct: f64,
pub session_text: String,
pub weekly_pct: f64,
@@ -83,7 +86,7 @@ pub fn is_visible() -> bool {
.unwrap_or(false)
}
pub fn current_model() -> Option<TrayIconKind> {
pub fn current_model() -> Option<ProviderId> {
lock_state().as_ref().map(|p| p.data.model)
}
@@ -118,6 +121,8 @@ pub fn show(data: PanelData, anchor_hwnd: HWND) {
},
};
apply_win11_window_chrome(hwnd);
{
let mut guard = lock_state();
if let Some(p) = guard.as_mut() {
@@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
PCWSTR::from_raw(class_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
WS_POPUP | WS_BORDER,
WS_POPUP,
x,
y,
w,
@@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
}
}
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
/// returns an error on Win10 and earlier, which we deliberately swallow so
/// the panel falls back to square corners without complaint. Idempotent:
/// DWM ignores redundant identical-value sets, so calling this on every
/// `show()` is safe.
fn apply_win11_window_chrome(hwnd: HWND) {
unsafe {
let pref = DWMWCP_ROUND;
let _ = DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE,
&pref as *const _ as *const _,
std::mem::size_of_val(&pref) as u32,
);
}
}
pub fn hide() {
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
if let Some(hwnd) = hwnd_opt {
@@ -269,21 +291,17 @@ fn paint(hwnd: HWND, hdc: HDC) {
} else {
Color::from_hex("#D6D6D6")
};
let accent = bar_color_for(data.model, data.session_pct.max(data.weekly_pct), data.is_dark);
let session_accent =
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct);
let weekly_accent =
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct);
unsafe {
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
FillRect(hdc, &rc, bg_brush);
let _ = DeleteObject(bg_brush);
// 4-px accent stripe matching the bubble — same provider color so the
// identity carries across both surfaces. Codex is theme-aware so a
// pure white stripe doesn't vanish into the light-mode background.
let stripe_color = match (data.model, data.is_dark) {
(ProviderId::Claude, _) => Color::from_hex("#D97757"),
(ProviderId::ChatGpt, true) => Color::from_hex("#FFFFFF"),
(ProviderId::ChatGpt, false) => Color::from_hex("#2A2A2A"),
};
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
let stripe_w = scaled(4);
let stripe_rect = RECT {
left: 0,
@@ -333,7 +351,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
&data.session_text,
text_color,
track,
accent,
session_accent,
dpi,
);
@@ -349,7 +367,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
&data.weekly_text,
text_color,
track,
accent,
weekly_accent,
dpi,
);
}
@@ -482,10 +500,6 @@ fn draw_text(
}
}
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
crate::bubble::bar_fill_color(model, is_dark, percent)
}
fn clone_data() -> Option<PanelData> {
let guard = lock_state();
let p = guard.as_ref()?;
@@ -528,7 +542,3 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
}
(x, y)
}
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
((logical as i64) * (dpi as i64) / 96) as i32
}
+3 -4
View File
@@ -6,7 +6,6 @@ use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
use crate::bubble::DEFAULT_BUBBLE_SIZE;
use crate::usage::ProviderId;
type TrayIconKind = ProviderId;
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
// edge still passes the validator, but a position fully on a disconnected
@@ -52,19 +51,19 @@ pub struct BubblePositions {
}
impl BubblePositions {
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> {
match model {
ProviderId::Claude => self.claude,
ProviderId::ChatGpt => self.codex,
}
}
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) {
match model {
ProviderId::Claude => self.claude = Some(pos),
ProviderId::ChatGpt => self.codex = Some(pos),
}
}
pub fn reset(&mut self, model: TrayIconKind) {
pub fn reset(&mut self, model: ProviderId) {
match model {
ProviderId::Claude => self.claude = None,
ProviderId::ChatGpt => self.codex = None,
+8 -36
View File
@@ -9,8 +9,7 @@
use std::ffi::c_void;
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND;
use windows::Win32::Foundation::{BOOL, HWND};
use windows::Win32::Graphics::Gdi::{
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
DIB_RGB_COLORS, HBITMAP,
@@ -66,7 +65,7 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
if let Some(p) = percent {
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
if sweep > 0.0 {
let fill = usage_color(p);
let fill = usage_color(kind, p);
let mut paint = Paint::default();
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
paint.anti_alias = true;
@@ -116,33 +115,12 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
}
}
fn usage_color(percent: f64) -> [u8; 3] {
// Color gradient: soft orange (low usage) → red (near-cap).
let stops: [(f64, [u8; 3]); 5] = [
(0.0, [0xD9, 0x77, 0x57]),
(50.0, [0xD9, 0x77, 0x57]),
(75.0, [0xCC, 0x8C, 0x20]),
(90.0, [0xC4, 0x50, 0x20]),
(100.0, [0xB8, 0x20, 0x20]),
];
for pair in stops.windows(2) {
let (a_p, a_c) = pair[0];
let (b_p, b_c) = pair[1];
if percent <= b_p {
let span = (b_p - a_p).max(f64::EPSILON);
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
return [
lerp(a_c[0], b_c[0], t),
lerp(a_c[1], b_c[1], t),
lerp(a_c[2], b_c[2], t),
];
}
}
stops[stops.len() - 1].1
}
fn lerp(a: u8, b: u8, t: f64) -> u8 {
(a as f64 + (b as f64 - a as f64) * t).round() as u8
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
/// ring readable (Codex sweep stays white instead of charcoal).
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
let c = crate::usage_color::bar_fill_color(kind, true, percent);
[c.r, c.g, c.b]
}
// ---------- Pixmap → HICON ----------
@@ -220,9 +198,3 @@ fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
hicon
}
}
// Silence import warnings if we end up not needing PCWSTR after later edits.
#[allow(dead_code)]
const _: PCWSTR = PCWSTR::null();
use windows::Win32::Foundation::BOOL;
+46 -20
View File
@@ -133,7 +133,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
fn bucket_to_window(bucket: Bucket) -> Window {
Window {
utilization: bucket.utilization,
utilization: bucket.utilization.clamp(0.0, 100.0),
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
}
}
@@ -163,18 +163,21 @@ fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
now_ms >= exp_ms
}
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") ---
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]") ---
fn parse_iso8601(s: &str) -> Option<SystemTime> {
let trimmed = s.split('Z').next().unwrap_or(s);
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
// We want the original `s` for parsing time-part. Re-split on 'T'.
let (date, time) = s
.split_once('T')
.map(|(d, t)| (d, t))
.or_else(|| Some(("", "")))?;
let _ = trimmed; // shadow; using the raw `date` + `time` below.
let (date, time_with_offset) = s.split_once('T')?;
// Split the time-and-offset on the first 'Z' / '+' / '-' marker.
let offset_pos = time_with_offset
.char_indices()
.find(|(_, c)| matches!(c, 'Z' | '+' | '-'))
.map(|(i, _)| i);
let (time, offset_str) = match offset_pos {
Some(p) => (&time_with_offset[..p], &time_with_offset[p..]),
None => (time_with_offset, ""),
};
let time = time.split_once('.').map_or(time, |(t, _)| t);
let date_parts: Vec<&str> = date.split('-').collect();
if date_parts.len() != 3 {
@@ -183,13 +186,16 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
let y: u64 = date_parts[0].parse().ok()?;
let mo: u64 = date_parts[1].parse().ok()?;
let d: u64 = date_parts[2].parse().ok()?;
if y < 1970 || mo == 0 || mo > 12 || d == 0 {
return None;
}
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let max_day = DAYS_IN_MONTH[mo as usize] + if mo == 2 && is_leap(y) { 1 } else { 0 };
if d > max_day {
return None;
}
let time_no_offset = time
.split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0)))
.next()
.unwrap_or(time);
let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset);
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
let time_parts: Vec<&str> = time.split(':').collect();
if time_parts.len() != 3 {
return None;
}
@@ -197,11 +203,26 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
let mi: u64 = time_parts[1].parse().ok()?;
let se: u64 = time_parts[2].parse().ok()?;
let offset_minutes: i64 = if offset_str.is_empty() || offset_str == "Z" {
0
} else {
let sign: i64 = if offset_str.starts_with('+') {
1
} else if offset_str.starts_with('-') {
-1
} else {
return None;
};
let (oh_str, om_str) = offset_str[1..].split_once(':')?;
let oh: i64 = oh_str.parse().ok()?;
let om: i64 = om_str.parse().ok()?;
sign * (oh * 60 + om)
};
let mut days: u64 = 0;
for year in 1970..y {
days += if is_leap(year) { 366 } else { 365 };
}
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for month in 1..mo {
days += DAYS_IN_MONTH[month as usize];
if month == 2 && is_leap(y) {
@@ -209,8 +230,13 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
}
}
days += d - 1;
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
Some(UNIX_EPOCH + Duration::from_secs(secs))
let local_secs = days * 86_400 + h * 3_600 + mi * 60 + se;
let utc_secs = (local_secs as i64) - offset_minutes * 60;
if utc_secs < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(utc_secs as u64))
}
fn is_leap(year: u64) -> bool {
+1 -1
View File
@@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
fn window_from(w: ApiWindow) -> Window {
Window {
utilization: w.used_percent,
utilization: w.used_percent.clamp(0.0, 100.0),
resets_at: unix_to_systemtime(Some(w.reset_at)),
}
}
+6 -2
View File
@@ -15,14 +15,18 @@ use crate::usage::{UsageWindows, Window};
pub fn parse_anthropic(response: &Response) -> UsageWindows {
UsageWindows {
primary: Window {
utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
utilization: (header_f64(response, "anthropic-ratelimit-unified-5h-utilization")
* 100.0)
.clamp(0.0, 100.0),
resets_at: unix_to_systemtime(header_i64(
response,
"anthropic-ratelimit-unified-5h-reset",
)),
},
secondary: Window {
utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
utilization: (header_f64(response, "anthropic-ratelimit-unified-7d-utilization")
* 100.0)
.clamp(0.0, 100.0),
resets_at: unix_to_systemtime(header_i64(
response,
"anthropic-ratelimit-unified-7d-reset",
+41
View File
@@ -0,0 +1,41 @@
// Shared usage→color ramp used by the floating bubble, the expanded panel,
// and the tray badge. Keeping the function in one place ensures the three
// surfaces never disagree about what "78% used" looks like.
use crate::os::Rgb as Color;
use crate::usage::ProviderId;
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex =
/// OpenAI brand teal `#10A37F`, used consistently across dark and light
/// themes so the badge/bubble/panel never disagree on Codex identity.
pub fn accent_color_for(model: ProviderId, _is_dark: bool) -> Color {
match model {
ProviderId::Claude => Color::from_hex("#D97757"),
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
}
}
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the
/// warning bands are theme-aware so light-mode amber stays readable against
/// the `#F3F3F3` background.
///
/// - <60% → provider accent
/// - 6080% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
/// - 8095% → red `#C45020`
/// - ≥95% → deep red `#A01818` — paired with pulse animation
pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
if percent < 60.0 {
accent_color_for(model, is_dark)
} else if percent < 80.0 {
if is_dark {
Color::from_hex("#E0A040")
} else {
Color::from_hex("#B47A20")
}
} else if percent < 95.0 {
Color::from_hex("#C45020")
} else {
Color::from_hex("#A01818")
}
}