Compare commits

..
11 Commits
Author SHA1 Message Date
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
20 changed files with 1005 additions and 622 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.11"
version = "0.2.0"
dependencies = [
"dirs",
"embed-resource",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.11"
version = "0.2.0"
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();
}
+453 -438
View File
File diff suppressed because it is too large Load Diff
+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"
);
}
}
+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")
}
}