Compare commits

...
31 Commits
Author SHA1 Message Date
tiennm99 38752ae5c5 chore: bump version to 0.3.13 2026-06-02 11:09:35 +07:00
tiennm99 e099f0a91f fix(update): restore helper-based self-update 2026-06-02 10:27:31 +07:00
tiennm99 4e9a66cbc8 chore: bump version to 0.3.12 2026-06-01 16:55:59 +07:00
tiennm99 1d9c59b43b chore: bump version to 0.3.11 2026-06-01 16:10:43 +07:00
tiennm99 7039bf7e98 fix: pass updater executable path to CreateProcess 2026-06-01 16:09:28 +07:00
tiennm99 cc73c3f9b4 chore: bump version to 0.3.10 2026-06-01 15:36:26 +07:00
tiennm99 a68d80415b chore: bump version to 0.3.9 2026-06-01 15:05:35 +07:00
tiennm99 cce22cc3b7 fix: run self-update through detached helper 2026-06-01 15:04:16 +07:00
tiennm99 5e00009e5a chore: bump version to 0.3.8 2026-06-01 14:37:26 +07:00
tiennm99 c91dc996f8 fix: tighten fullscreen auto-hide detection 2026-06-01 14:35:26 +07:00
tiennm99 b2e48cc119 chore: bump version to 0.3.7 2026-05-25 11:37:34 +07:00
tiennm99 bee9f3cfa0 fix(ui): use theme-aware monochrome accent for Codex 2026-05-25 11:37:29 +07:00
tiennm99 860eb62d1b chore: bump version to 0.3.6 2026-05-25 11:15:13 +07:00
tiennm99 c424b17bd7 fix(ui): reverse remaining-time direction to read like a clock
Inner ring's consumed wedge now grows clockwise from 12 o'clock (mirroring
a clock-hand countdown) and the tail remaining-time bar shrinks toward the
right edge instead of the left. Usage ring and weekly usage bar unchanged.
2026-05-25 11:15:07 +07:00
tiennm99 3b0f96f51c chore: bump version to 0.3.5 2026-05-23 23:36:41 +07:00
tiennm99 5976181cb2 fix(ui): improve bubble visual hierarchy and contrast
- Lift inner time ring above WCAG 1.4.11 (track #303030 -> #404040,
  stroke floor 1 -> 2 logical, ring gap 3 -> 4).
- Breathe tail bar/text with bar_text_gap=8 (was pad=6); right inset
  12 -> 14 logical so text clears the stadium end-cap.
- Reweight typography: head percent FW_BOLD, "5H" tag and tail percent
  FW_SEMIBOLD, tail countdown stays normal but takes muted color so
  the percent reads as the headline.
- Tone down usage track (#3A3A3A/#D6D6D6 -> #2C2C2C/#E2E2E2) so fill
  dominates at low percentages.
- Differentiate lane mass: usage bar 9%/5..12 -> 10%/6..12, time bar
  5%/3..7 -> 4%/3..6, lane gap 5 -> 6. Time bar now reads as supporting
  context, not a competing quota.
- Min-fill guard on weekly bar: sub-cap fills floor at one cap-diameter
  so 1% renders as a recognizable dot.
- head_pad 4 -> 5; big-font ratio 26% -> 24% of head diameter (BOLD
  compensates for the size cut).
2026-05-23 23:36:34 +07:00
tiennm99 f2b31d3211 fix(ui): align tail bar text layout 2026-05-23 22:53:36 +07:00
tiennm99 5c2b14fc03 feat(ui): show remaining time progress 2026-05-23 22:31:37 +07:00
tiennm99 51889d3c39 fix(ui): scale weekly bar thickness 2026-05-23 21:43:11 +07:00
tiennm99 6661a7a10b chore: bump version to 0.3.1 2026-05-23 21:07:05 +07:00
tiennm99 6cafffc883 fix(bubble): show 7d percent at default size and 125% DPI
v0.3.0 introduced a tail 7d% reading but the layout-collapse guard
fired at every common bubble configuration — at the default 200-logical
size on both 100% and 125% DPI, after reserving the CJK worst-case
countdown column ("999시간") and the "100%" text rect, the tail had
less than 20 logical of bar room left. The guard collapsed the % rect
to zero width and the paint code's `if rect.right > rect.left` skip
ran on every frame, so the feature was effectively dead on arrival
for the majority of users.

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

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

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

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

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

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

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

cargo check: clean. cargo test: 2/2. cargo clippy: 13 warnings
(unchanged baseline).
2026-05-23 20:32:31 +07:00
tiennm99 a3f1323154 chore: bump version to 0.2.0 2026-05-23 18:18:34 +07:00
tiennm99 081a70a537 feat(ui): improve bubble controls discoverability 2026-05-23 18:17:25 +07:00
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
21 changed files with 2067 additions and 508 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.12"
version = "0.3.13"
dependencies = [
"dirs",
"embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.12"
version = "0.3.13"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
+7 -4
View File
@@ -28,7 +28,9 @@ self-updater are all written from scratch against the same public APIs
Codex usage as a percentage and a colored progress ring
- Drag anywhere — the bubble snaps to monitor work-area edges when
released
- Resize with `Ctrl + MouseWheel` on the bubble (32128 pixels)
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls**
**Make smaller / Make larger / Reset size** from the right-click menu
(140360 logical pixels)
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed models, update frequency, language,
@@ -84,10 +86,11 @@ release to snap to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for refresh, models, refresh frequency, language, "Start
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
manual "Check for updates", exit
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
Weekly), manual "Check for updates", exit
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble to resize it
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
menu, to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
right-click opens the same menu
@@ -0,0 +1,57 @@
---
phase: 1
title: "Lock geometry"
status: pending
priority: P1
effort: "45m"
dependencies: []
---
# Phase 1: Lock geometry
## Overview
Define the tail layout contract in `compute_bubble_layout` so both rows consume the same horizontal geometry and differ only in vertical placement and bar height.
## Requirements
- Functional: percent text and countdown text appear after the bar, not inside it.
- Functional: `tail_usage_bar_rect.left/right == tail_time_bar_rect.left/right`.
- Functional: `tail_usage_pct_rect.left/right == tail_time_text_rect.left/right`.
- Non-functional: preserve the 140-360 logical size behavior and the minimum bar-width guard.
- Non-functional: no new renderer inputs from `src/app.rs`.
## Architecture
- Data flow: `src/app.rs:639-655` -> `bubble::update_data` -> `PaintInputs` -> `compute_bubble_layout` -> `paint_bubble_pixmap` / `paint_bubble_text`.
- Geometry source of truth: shared `text_w`, `text_left`, `bar_left`, and `bar_right` in `src/bubble.rs:1114-1126`.
- Current rect assignment already fans those shared values into both tail rows at `src/bubble.rs:1141-1163`.
## Related Code Files
- Modify: `src/bubble.rs`
- Read-only check: `src/app.rs`
## Implementation Steps
1. Re-verify the live mismatch before changing code; the current source already shares bar and text columns.
2. Make the target contract explicit in `compute_bubble_layout`: one shared bar lane, one shared text lane, row-specific `top/bottom` only.
3. Preserve `bar_min` fallback so long countdowns shrink text first, not bar width below usability.
4. Keep bar-height asymmetry unless the user confirms that equal thickness is also required.
## Todo List
- [ ] Confirm whether the bug is still reproducible on `main`.
- [ ] Document the target geometry near `compute_bubble_layout`.
- [ ] Ensure no later row-specific width override remains.
## Success Criteria
- [ ] Both tail bars have identical `left/right` bounds.
- [ ] Both tail texts start at the same `left` and end at the same `right`.
- [ ] No upstream data-contract change is required.
## Risk Assessment
- High: the source may already satisfy the request; unnecessary edits would add churn. Mitigation: prove the runtime mismatch first.
- Medium: long localized countdown strings can starve bar width at minimum size. Mitigation: keep `bar_min` and shared fallback math.
- Rollback: revert only the `compute_bubble_layout` diff.
## Security Considerations
- None beyond normal memory-safety review; change is layout-only.
## Next Steps
- Hand off the shared-geometry contract to Phase 2 for text painting and stale-comment cleanup.
@@ -0,0 +1,57 @@
---
phase: 2
title: "Apply renderer change"
status: pending
priority: P2
effort: "45m"
dependencies: [1]
---
# Phase 2: Apply renderer change
## Overview
Apply the tail text-placement change in the renderer so the weekly percent lane and weekly remaining-time lane follow the same `bar -> text` behavior, without changing provider or state plumbing.
## Requirements
- Functional: weekly percent text renders from `tail_usage_pct_rect`; weekly countdown renders from `tail_time_text_rect`.
- Functional: both texts stay right-aligned after the bar using `DT_RIGHT`.
- Functional: no tail text is drawn inside the bar fill.
- Non-functional: preserve the existing pulse behavior for `weekly_pct >= 95`.
- Non-functional: avoid touching `PaintInputs`, polling, or panel code unless a stale comment must be corrected.
## Architecture
- Bar drawing is tiny-skia-only in `src/bubble.rs:1298-1331`.
- Tail text drawing is a later GDI overlay in `src/bubble.rs:1643-1661`.
- `src/app.rs:636-638` currently describes the bubble percent as inline in the bar fill; if that wording is now false, correct it in the same phase.
## Related Code Files
- Modify: `src/bubble.rs`
- Optional modify: `src/app.rs`
## Implementation Steps
1. Align `paint_bubble_text` with the Phase 1 geometry contract and keep the percent/countdown text outside the bars.
2. Remove any remaining inline-percent assumption in comments or naming if it conflicts with the final behavior.
3. Keep the weekly percent highlight behavior and empty-countdown handling intact.
4. Stop scope creep: no changes to provider snapshots, update timers, or panel layout.
## Todo List
- [ ] Confirm `paint_bubble_text` is the only text-placement site for the tail rows.
- [ ] Update or remove stale inline-bar comments if they become misleading.
- [ ] Re-check placeholder and `None` states after the layout change.
## Success Criteria
- [ ] Weekly percent text appears after the top tail bar.
- [ ] Weekly countdown text appears after the bottom tail bar.
- [ ] Tail bar widths are driven only by shared geometry from `compute_bubble_layout`.
## Risk Assessment
- Medium: if the reported mismatch is only visual perception from unequal bar heights, text-placement edits alone will not fix it. Mitigation: compare runtime screenshots before and after Phase 1.
- Low: optional comment cleanup in `src/app.rs` can drift from renderer reality. Mitigation: change comments only after the final behavior is locked.
- Rollback: revert only the text-placement and comment diffs.
## Security Considerations
- None; no auth, network, or filesystem behavior changes.
## Next Steps
- Hand off to Phase 3 for compile checks and Windows visual verification.
@@ -0,0 +1,64 @@
---
phase: 3
title: "Validate on Windows"
status: pending
priority: P2
effort: "30m"
dependencies: [2]
---
# Phase 3: Validate on Windows
## Overview
Verify that the scoped renderer change compiles and that the native layered-window bubble actually presents equal-width tail bars with text after each bar across common runtime conditions.
## Requirements
- Functional: both tail rows visually render as `bar -> text`.
- Functional: both tail bars have the same visible width.
- Non-functional: confirm no regression to head ring, head text, or tray/panel refresh behavior.
- Non-functional: validation stays command-light and uses the existing Windows runtime.
## Architecture
- Compile-time validation covers the Rust renderer path end to end.
- Runtime validation must observe the real layered window because there are no snapshot/golden tests for `tiny-skia + GDI` composition in this repo.
## Related Code Files
- Verify: `src/bubble.rs`
- Verify if touched: `src/app.rs`
## Implementation Steps
1. Run the compile/test commands below.
2. Launch the app and verify the bubble at 140, default, and max logical sizes.
3. Check light and dark theme, Claude and Codex bubbles, and a long countdown string if available.
4. Capture before/after notes so a no-op or perception-only result is explicit.
## Validation Commands
```powershell
cargo check
cargo test
cargo run
```
## Todo List
- [ ] `cargo check` passes.
- [ ] `cargo test` passes, or any pre-existing failures are called out separately.
- [ ] Manual runtime check confirms equal bar widths and text-after-bar alignment.
- [ ] No regression is seen in the head ring/text or bubble refresh path.
## Success Criteria
- [ ] Compile succeeds on the current branch.
- [ ] The top and bottom tail bars share the same left/right edges at runtime.
- [ ] The percent and countdown texts both sit to the right of their bars at runtime.
- [ ] Any remaining mismatch is explained with evidence, not assumption.
## Risk Assessment
- High: native renderer issues are hard to prove without manual observation. Mitigation: test at minimum/default/maximum sizes and common DPI settings.
- Medium: reproducing the original complaint may require a specific locale, DPI, or stale binary. Mitigation: record the runtime conditions used during verification.
- Rollback: revert the renderer change if compile or visual regression appears.
## Security Considerations
- None.
## Next Steps
- If validation passes, implementation can be approved as a scoped `src/bubble.rs` change. If not, reopen Phase 1 with the observed runtime evidence.
@@ -0,0 +1,50 @@
---
title: "Bubble tail bar layout alignment"
description: "Scoped renderer-only plan to align weekly percent and remaining-time tail bar geometry."
status: pending
priority: P2
effort: 2h
branch: "main"
tags: [rust, renderer, bubble, layout]
blockedBy: []
blocks: []
created: 2026-05-23
createdBy: "ck:plan"
source: skill
---
# Bubble tail bar layout alignment
## Scope
- User-facing goal: weekly percent lane and weekly remaining-time lane both render as `bar -> text`, and both bars share identical left/right bounds.
- Expected code scope: `src/bubble.rs`; touch `src/app.rs` only if comment cleanup is needed.
- Backwards compatibility: no settings, storage, IPC, or provider-data changes.
## Verified Codebase Facts
- Bubble data already provides `weekly_pct`, `weekly_text`, and `weekly_resets_at` through `bubble::update_data`; no new inputs are needed (`src/app.rs:639-655`).
- `compute_bubble_layout` already derives one shared text column and one shared bar lane for the two tail rows (`src/bubble.rs:1114-1163`).
- `paint_bubble_text` already renders weekly percent and weekly countdown as separate right-aligned texts (`src/bubble.rs:1644-1661`).
- `paint_bubble_pixmap` paints both tail bars from rects only; text is a GDI overlay, so geometry must stay the single source of truth (`src/bubble.rs:1298-1331`).
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Lock geometry](./phase-01-lock-geometry.md) | Pending |
| 2 | [Apply renderer change](./phase-02-apply-renderer-change.md) | Pending |
| 3 | [Validate on Windows](./phase-03-validate-on-windows.md) | Pending |
## Dependencies
- Sequence: Phase 1 -> Phase 2 -> Phase 3.
- File ownership: `src/bubble.rs` stays single-owner across phases; optional `src/app.rs` comment cleanup happens only in Phase 2.
- Related existing plan: `plans/260523-ui-ux-improvement-plan/plan.md` Phase 3 overlaps in theme but does not block this scoped renderer-only change.
## Rollback
- Revert layout math and text-placement changes in `src/bubble.rs`.
- Revert optional comment cleanup in `src/app.rs`.
- No data migration or persisted-state rollback is needed.
## Unresolved Questions
- Does "same length" mean equal width only, or should the two tail bars also share the same height? Current code intentionally uses different heights (`src/bubble.rs:1105-1110`).
- The current source already looks close to the requested behavior. If runtime still differs, is the issue in this branch, a stale binary, or perception caused by different bar heights?
@@ -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?
+221
View File
@@ -0,0 +1,221 @@
# UI Design Review — Floating Bubble
Date: 2026-05-23
Scope: Visual-only redesign of the stadium bubble (head ring + tail bars).
Constraints: tiny-skia primitives only (AA fills, AA stroked arcs, AA pills) + GDI text. No new deps. No animations beyond existing pulse. Logical px values; `scale_to_dpi` handles HiDPI.
Files reviewed:
- `D:\tiennm99\claude-code-usage-bubble\src\bubble.rs` (lines 10321703)
- `D:\tiennm99\claude-code-usage-bubble\src\usage_color.rs`
- `D:\tiennm99\claude-code-usage-bubble\src\os\color.rs`
---
## 1. Issues Found
### A. Information hierarchy is flat
The big head "31%" and the tail "64%" are typographically equal in weight against their backgrounds — but they answer different questions (now vs. weekly). The eye has no anchor. Add weight contrast.
### B. Tail text is cramped
`tail_usage_pct_rect` and `tail_time_text_rect` share the same right edge (`content_right`) with no rule for the gap between the bar end and the percent label. With `pad = 6 logical` and `pct_reserve_w` literally just `measure("100%") + 2 logical`, the "64% / 3d" pair reads as one glyph blob. The two right-aligned tokens stack with only ~2 logical px of internal breathing.
### C. The inner time ring is nearly invisible
At ring_stroke_w 3 logical and time_ring_stroke_w 2 logical with only a 3-logical gap between them (line 1074), and using `#303030` track on a `#1F1F1F` background, the ratio is ~1.13:1. Below the visibility threshold; users won't read it as a ring.
### D. Two bubbles read as one merged blob
Claude and Codex stagger vertically by `height_px + gap=24` (line 1750) but visually the dark-on-dark stadia float without anchor — there's no provider identifier inside the bubble itself. The accent color is the *only* differentiator, and it disappears below 60% (Codex teal vs. orange both reduce to white at that range for tail text — see line 1646).
### E. Track contrast vs. fill is loud
`track = #3A3A3A` on `#1F1F1F` bg (4.0:1) is louder than the fill at low percentages. At 5% usage the dim track screams more than the bright fill — backwards visual priority.
### F. Time-bar reads as a second-quota
The grey time bar fills *left-to-right* same direction as the usage bar, and shares the same shape, position, and visual weight class. A user glancing sees "two progress bars" and assumes both are quotas. The grey hue helps but the *gestalt* fights it.
### G. Head "5h" label is buried
`small_font_px ≈ 55% of big_font_px` and uses `#888888`. At 200 logical width the label is ~10px and dim. It's the only thing telling the user the ring is the *5-hour* window.
### H. Ring uses round caps but track does not — visual mismatch
Active arc has `LineCap::Round` (line 1249) but the track is a full circle. At low percentages the rounded start cap juts out above the track — looks unfinished. The track should be `LineCap::Butt` (default closed circle is fine) but the *visual idiom* would benefit from the track being a hint subtler.
### I. Corner radius of pill = `canvas_h / 2` is fine, but the head circle inscribed in the same height feels visually small
`ring_radius = head_diameter/2 - 4 - stroke/2` makes the ring fill ~92% of the head square — but the head_square equals the canvas height, so the head looks slightly under-sized vs. the visual weight of the tail bars. Slight padding nudge.
### J. No separator/cue between the two stacked bubbles
Not a per-bubble issue, but worth noting: when both providers run, a faint provider mark inside each bubble would let users disambiguate without remembering "the upper one is Codex."
---
## 2. Proposed Changes
All values are **logical px**. Hex colors are dark-theme; the light-theme entry shown after `/`.
### Change 1 — Demote the head "5h" label, promote into a chip
**What:** Keep the small label, but render it as an uppercase, letter-spaced micro-cap inside a 1-px-stroke pill (no fill).
**Why:** Reads as a "window selector" tag rather than disambiguated noise.
**Values:**
- text: `"5H"` (uppercase, was `"5h"`)
- font weight: `FW_SEMIBOLD` (was normal)
- letter-spacing: simulate via `+1 logical px` between glyphs — actually, just keep tracking from font; the uppercase alone reads stronger.
- color: `#A8A8A8` / `#5E5E5E` (was `#888888` / `#6E6E6E`)
- no chip border for v1 — KISS. Just style the text. If chip is wanted later, AA stroke a pill rect.
- Keep current vertical position; the `label_pct_gap` is fine.
### Change 2 — Bump big-percent weight + tighten size
**What:** Big number gets heavier and slightly smaller; tightens visual mass.
**Why:** Heavier weight = stronger anchor without taking more space.
**Values:**
- weight: `FW_BOLD` (was `FW_SEMIBOLD`)
- size factor: `big_font_px = head_diameter * 24/100` (was `26/100`)
- color unchanged: `#EAEAEA` / `#1F1F1F`
### Change 3 — Lift the inner time ring above noise
**What:** Increase contrast of the time-ring track and fill; thicken slightly.
**Why:** Current ratio of 1.13:1 against bg is invisible. Per WCAG 1.4.11 non-text 3:1 minimum.
**Values:**
- `time_ring_stroke_w`: `scale_to_dpi(2, dpi).clamp(2, 3)` (was `1..3`, effective 1px floor → too thin)
- gap between outer ring inner edge and inner ring outer edge: `4 logical` (was `3`)
- time_track: `#2F2F2F`**`#404040`** (3.5:1) / light unchanged
- time_fill (used for inner-ring active arc *and* tail time-bar fill): `#9A9A9A`**`#B0B0B0`** / `#777777``#666666`
- Keep `LineCap::Round` on the active arc.
### Change 4 — Reserve a real gap between tail bar and tail text
**What:** Add a `bar_text_gap = 8` between bar end and text left edge (currently `pad = 6`).
**Why:** Eight is the eyeballed minimum where the eye registers "two columns" instead of "one wall of glyphs."
**Values:**
- new constant: `bar_text_gap = scale_to_dpi(8, dpi)` (was effectively `pad = 6`)
- `bar_right = (text_left - bar_text_gap).max(bar_left + bar_min);` (line 1126)
- `pad` stays `6` for the head→tail content_left inset.
### Change 5 — Right-edge inset
**What:** Increase inner right margin of the tail.
**Why:** The current `scale_to_dpi(12, dpi)` insetinto the pill's rounded right cap leaves text near the curvature. Bump to clear the cap visually.
**Values:**
- `tail_right = width_px - scale_to_dpi(14, dpi)` (was `12`)
### Change 6 — Reweight tail percent vs. tail countdown
**What:** Make the tail percent a touch heavier than the countdown so the *number* anchors the lane.
**Why:** Today both are FW_NORMAL same size, both `text_color` — flat. Bigger number with smaller dimmer suffix establishes hierarchy.
**Values:**
- weekly percent: `FW_SEMIBOLD`, color `text_color` (`#EAEAEA` / `#1F1F1F`)
- weekly countdown: `FW_NORMAL`, color **`muted_color`** (`#A8A8A8` / `#5E5E5E`) — was `text_color`
- Font sizes unchanged: both `small_font_px` / `main_font_px`.
### Change 7 — Tone down the usage-bar track
**What:** Drop track contrast so the *fill* dominates, not the track.
**Why:** At low percent (510%) the bright track outscreams the fill. Track should be a hint.
**Values:**
- `track`: `#3A3A3A`**`#2C2C2C`** (was 4.0:1 vs. bg; now 1.6:1 — the *fill* hits 4.5:1+ from accent colors and carries the signal)
- light theme: `#D6D6D6``#E2E2E2`
### Change 8 — Differentiate the time-bar shape from the usage-bar shape
**What:** Make the time bar visibly *thinner and lower-contrast* so it doesn't read as a second quota.
**Why:** Current ratio: usage_bar 9% of height, time_bar 5% — close. Push the spread.
**Values:**
- `usage_bar_h = (height_px * 10 / 100).clamp(6, 12)` (was 9% / clamp 512)
- `time_bar_h = (height_px * 4 / 100).clamp(3, 6)` (was 5% / clamp 37)
- `lane_gap = scale_to_dpi(6, dpi)` (was 5)
- This gives the usage bar ~2.5× the visual mass of the time bar — clear "primary" vs. "context."
### Change 9 — Pull the head ring in by 1 px so the head circle feels deliberate
**What:** Slightly more head padding; ring sits 1 logical px farther in.
**Why:** Ring currently kisses the visual edge of the head square; a touch of breathing room makes the head feel composed and balances vs. the heavier tail.
**Values:**
- `head_pad = scale_to_dpi(5, dpi)` (was 4)
- `ring_stroke_w`: keep `scale_to_dpi(3, dpi).clamp(2, 4)` — already good.
### Change 10 — Provider mark dot (subtle disambiguator)
**What:** A 4×4 logical solid circle in the accent color, positioned at the *outer* edge of the ring at 12 o'clock — between the ring and the head's left edge.
**Why:** Today the only provider tell is the accent of the active arc; below 60% the arc *is* the accent so it works, but at >60% the arc shifts to amber/red and the provider identity vanishes. A constant dot fixes that. Also helps when two bubbles stack.
**Values:**
- center: `(ring_cx, ring_cy - ring_radius - ring_stroke_w/2 - 4)` — i.e. 4 logical px above the ring's outer edge
- radius: `scale_to_dpi(2, dpi)` (logical 2 → diameter 4)
- color: `accent_color_for(model, is_dark)` (existing function — `#D97757` Claude / `#10A37F` Codex)
- Implemented as one extra `pb.push_circle(...)` fill before the ring strokes — zero new dependencies.
### Change 11 — Round-cap the active tail bars; flat-cap the tracks
**What:** Keep the existing `paint_pill` for the *track*, but reduce its end-cap radius. For the *fill*, keep full cap. Actually, simpler: leave both as full-cap pills (current behavior) — just ensure the fill never paints below `2 * cap` width.
**Why:** Already correct in code (`paint_pill` does both end-caps). No change needed visually, but lock in a min-fill so the bar at 1% doesn't render as a dot.
**Values:**
- in the weekly-pct render block (line 1300+): if `fill_w > 0.0 && fill_w < bar_h`, set `fill_w = bar_h` (a one-cap-diameter minimum). Cosmetic only — preserves "I see some progress" cue when usage is 0.12%.
### Change 12 — Text-color for tail percent when bar is in alarm range
**What:** When `weekly_pct >= 95`, tint the percent text toward the alarm color instead of bumping its luminance via `brighten` only.
**Why:** Today, at 98%, the text just gets *brighter* via the pulse — but in dark mode the bar is already pulsing deep red. Tinting the number red ties it to the bar.
**Values:**
- if `pct >= 95.0`: `text_color_for_pct = #E08070` (dark) / `#B02810` (light), then apply pulse `brighten` on top.
- Keep the FW_SEMIBOLD from Change 6.
- 80 ≤ pct < 95: leave at default text color (the bar carries the warning).
---
## 3. ASCII Mockup (one bubble, dark, ~270 logical px wide)
```
canvas_w = 270 (logical)
<─────────────────────────────────────────────────────────────────>
┌─────────────────────────────────────────────────────────────────┐
│ • <─ accent dot (2-logical r, 4 above ring outer) │
│ ╭───╮ │
│ / \ ┌─────────────────────────────────────┐ │ ^
│ │ ┌───┐ │ │▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░│ 64% │ ←lane │ |
│ │ │5H │ │ └─────────────────────────────────┴─────┘ │ |
│ │ │31%│ │ ┌─────────────────────────────┐ 3d ←FW_NORMAL│ | height
│ │ └───┘ │ │██░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ muted│ |
│ \ / └─────────────────────────────┘ │ |
│ ╰───╯ │ v
└─────────────────────────────────────────────────────────────────┘
<──head_diameter──><pad=6><────────bar_w────────><gap=8><text_w>
= canvas_h <r-inset=14>
```
Key:
- `•` accent dot (4-logical px diameter, provider color)
- Outer thick ring = 5h usage (active arc in accent / amber / red)
- Inner thinner ring = 5h remaining time (now `#B0B0B0` on `#404040`, visible)
- `5H` = uppercase semibold label, `muted` color, sits above the big number
- `31%` = big bold pct, `text_color`
- `▓▓▓` top tail bar = weekly usage fill (accent/amber/red), `usage_bar_h ≈ 10% of canvas_h`
- `░░░` track = `#2C2C2C` (toned down)
- `64%` = right-aligned, FW_SEMIBOLD, text_color
- `██░░` bottom tail bar = remaining time, `time_bar_h ≈ 4% of canvas_h` (visibly thinner)
- `3d` = right-aligned, FW_NORMAL, muted_color
- 8-logical gap between bar end and right-aligned text column (Change 4)
---
## 4. Do Not Change
- **Overall stadium shape with `corner_radius = canvas_h/2`** — clean and iconic, photographs well in screenshots.
- **Head-on-left, tail-on-right layout** — well-established mental model; sweeping arc + horizontal bar = "circular thing for now-ish, linear thing for week-ish."
- **Accent ramp by percent** (60/80/95 thresholds in `usage_color.rs`) — solid color logic; don't touch.
- **Pulse animation at ≥95%** — subtle, draws the right amount of attention. Keep.
- **`paint_pill` two-circle + middle-rect construction** — exactly right for tiny-skia. Don't refactor to a rounded-rect path; the current is faster and AA-clean.
- **`LineCap::Round` on the active arc** — feels alive; the "unfinished" look at low % I mentioned in issue (H) is acceptable trade-off.
- **DPI scaling via `scale_to_dpi`** — keep all proposed values in logical px and let the function do its job.
- **Locale-aware countdown width via `COUNTDOWN_TEMPLATE = "999시간"`** — clever and right; preserve.
- **`DT_END_ELLIPSIS` on tail text** — graceful degradation at narrow widths.
- **Existing fallback from countdown to `"5H"` when label rect is too narrow** — keep, just change the static fallback to uppercase per Change 1.
- **Aspect ratio taper (`aspect_at_width`)** — tail breathes better at wider sizes; preserve.
---
## Implementation Notes
- All proposed color tokens belong inline in `paint_bubble_pixmap` and `paint_bubble_text` — no new modules needed.
- The accent dot (Change 10) is ~3 new lines in the ring block.
- Bar height and gap changes (Change 8) are 3 single-line edits in `compute_bubble_layout`.
- Min-fill (Change 11) is a one-line guard before `paint_pill` for the weekly fill.
- No new geometry struct fields needed. No new fonts. No new dependencies.
---
## Unresolved Questions
1. **Light-theme accent dot**: Claude `#D97757` and Codex `#10A37F` on `#F3F3F3` — Codex teal contrast is ~3.0:1, borderline. Accept (it's a 4-px decorative dot, not text) or use a darkened variant for light theme?
2. **Change 12 alarm-tint**: should it apply to the head big-pct too, or only to the tail pct? Current proposal is tail-only. Confirm whether the head should also tint red at ≥95%.
3. **Change 10 dot position**: 12 o'clock is canonical but could be at 1011 o'clock so the start of the active arc (which begins at 12 sweeping clockwise) doesn't visually merge with the dot. Open to either.
---
**Status:** DONE
**Summary:** Twelve concrete, primitive-implementable changes that hierarchically organize percentages, raise the invisible inner time ring above WCAG 1.4.11, breathe the cramped tail text by 8 logical px, and add a 4-px provider dot for identity — all without new deps or animation engines.
+187
View File
@@ -0,0 +1,187 @@
# UI Rendering Code Review
Scope: `src/bubble.rs` (renderer), `src/usage_color.rs`, `src/os/color.rs`,
drawing portions of `src/panel.rs`, `src/tray/badge.rs`.
## Findings
1. **`src/bubble.rs:1178-1197` — severity: high.** Eight neutral surface
colours (`#1F1F1F`, `#F3F3F3`, `#3A3A3A`, `#D6D6D6`, `#303030`, `#E0E0E0`,
`#9A9A9A`, `#777777`) are hex literals inside `paint_bubble_pixmap` and
duplicated near-verbatim in `panel.rs:279-293` (`#1F1F1F`, `#FAFAFA`,
`#EAEAEA`, `#3A3A3A`, `#D6D6D6`) plus `bubble.rs:1590-1597` text colours.
Three surfaces silently disagree (`#F3F3F3` bubble bg vs `#FAFAFA` panel
bg) and a designer cannot retune the palette without grepping. Fix:
centralise into a `palette` module alongside `usage_color.rs` exposing
`bg(is_dark)`, `track(is_dark)`, `time_track(is_dark)`, `time_fill(is_dark)`,
`text(is_dark)`, `muted(is_dark)`; have `panel.rs` consume the same helpers.
2. **`src/tray/badge.rs:53, 112, 114` — severity: high.** Badge colours go
through `paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255)` and raw
`[u8; 3]` arrays instead of `Rgb` / `os::color`. `#3A3A3A` already exists
as the shared "track" colour in `bubble.rs:1184`, but the tray hard-codes
it. The two Claude/Codex base tints (`#2A1F1C`, `#1A1F26`) also live only
here. Fix: route through the same palette module, even if the badge keeps
its own dark inner-disk variants (named constants beat magic byte arrays).
3. **`src/bubble.rs:1064-1117` — severity: high.** Padding/gap literals
`scale_to_dpi(2|4|5|6|8|12, dpi)` appear 15+ times inside
`compute_bubble_layout` with no naming. Same logical "edge padding"
(`scale_to_dpi(4, dpi)`) is used for `head_pad`, head-label left/right,
head-pct left/right (lines 1064, 1086, 1088, 1092, 1094); same "small
nudge" (`scale_to_dpi(2, dpi)`) is the ring stroke clamp, label/pct
row vertical breathing room, pct-reserve gap, and time-text padding. A
designer tweaking head-text padding will touch four lines and miss the
fifth. Fix: hoist named DPI-scaled constants at the top of the function
(`HEAD_PAD`, `TEXT_VPAD`, `LANE_GAP`, `TAIL_PAD`, `RIGHT_INSET`,
`BAR_MIN_W`) and reuse — same pattern `panel.rs` uses with its
`*_LOGICAL` constants (line 24-30).
4. **`src/panel.rs:333-340` — severity: med.** Bar-x / bar-w / row-y math
mixes `scaled(PADDING_LOGICAL)`, `scaled(LABEL_W_LOGICAL)`,
`scaled(RIGHT_TEXT_W_LOGICAL)` with bare `scaled(4)`, `scaled(8)`,
`scaled(24)`, `scaled(18)` — four un-named "small" values doing
semantically distinct jobs (label-bar gap, bar-text gap, header height,
row-1 offset). Fix: name them (`LABEL_BAR_GAP_LOGICAL`,
`BAR_TEXT_GAP_LOGICAL`, `HEADER_H_LOGICAL`, `HEADER_OFFSET_LOGICAL`) so
the row geometry is auditable in one place.
5. **`src/panel.rs:340` — severity: med.** `row2_y = row1_y +
scale_to_dpi(BAR_HEIGHT_LOGICAL, dpi) + scale_to_dpi(ROW_GAP_LOGICAL, dpi)
+ scaled(8)`. The trailing `+ scaled(8)` is an unexplained extra gap on
top of `ROW_GAP_LOGICAL`; this is exactly the inconsistency `ROW_GAP_LOGICAL`
was created to prevent. Fix: fold into `ROW_GAP_LOGICAL` (16) or rename
the extra into a labelled `ROW_TEXT_GAP_LOGICAL`.
6. **`src/bubble.rs:1099, 1126` — severity: med.** `tail_right = width_px -
scale_to_dpi(12, dpi)` and `bar_right = (text_left - pad).max(bar_left +
bar_min)`. The `12` is the right-edge inset to clear the stadium's right
end-cap; this is conceptually `corner_radius / 2`-ish but encoded as a
constant that won't track if aspect ratio changes. Fix: derive from
`layout.corner_radius` or hoist a `TAIL_RIGHT_INSET` constant with a
comment tying it to the end-cap curvature.
7. **`src/bubble.rs:1360-1377` and `src/tray/badge.rs:86-106` — severity:
med.** `build_arc` is duplicated verbatim between the bubble renderer
and the tray badge — same 64-segment sampling, same `FRAC_PI_2` start,
same edge-case `.max(1)` segment count. Fix: lift into a small
`geometry` / `tiny_skia_helpers` module shared by both call sites;
change neither call site to keep behaviour identical.
8. **`src/bubble.rs:1339-1352` — severity: med.** `paint_pill` is a perfect
helper candidate for `panel.rs`'s bar drawing — `panel.rs` uses
`FillRect` rectangles with hard corners (`draw_row` lines 406-428),
visually inconsistent with the bubble's rounded pill caps. Fix: extract
`paint_pill` to a shared rendering helper module and have panel use it
so the two surfaces have matching bar geometry. (Cross-surface
consistency was the stated reason for `usage_color.rs` existing — same
logic applies to bar shape.)
9. **`src/bubble.rs:1080-1083, 1111-1112` — severity: med.** `head_label_h`,
`head_pct_h`, `time_text_h`, `usage_pct_h` all add `scale_to_dpi(2, dpi)`
of "breathing room" to a font height, but never call it that — and the
computed rect height is then used by `DrawTextW` with `DT_VCENTER` so a
too-tight value would clip ascenders/descenders. Currently safe because
2 px (logical) ≈ font leading, but the magic `2` is load-bearing. Fix:
`const FONT_VPAD_LOGICAL: i32 = 2;` with a one-line comment "ascender/
descender slack for DT_VCENTER".
10. **`src/bubble.rs:1141-1146, 1153-1158` — severity: low.** The vertical
centring expression `usage_bar_top + (usage_bar_h - usage_pct_h) / 2`
is computed twice for `tail_usage_pct_rect` (top + bottom). Tiny but
if a designer asks "where does the % text sit relative to the bar?"
they have to mentally simplify. Fix: compute `pct_text_top` /
`time_text_top` as named locals before the struct literal.
11. **`src/bubble.rs:1076, 1077` — severity: low.** `big_font_px =
head_diameter * 26 / 100`; `small_font_px = big_font_px * 55 / 100`.
The 26 % and 55 % ratios are the core typographic scale of the head
text — promote to `BIG_FONT_RATIO_PCT`, `SMALL_TO_BIG_FONT_PCT`
constants with a "tweak these to retune head proportions" comment.
12. **`src/bubble.rs:1078` — severity: low (dead-code adjacent).**
`main_font_px = small_font_px;` — `main_font_px` is identical to
`small_font_px` but kept as a separate field on `BubbleLayout`
(line 1056) and used for the countdown (line 1604, 1658). If the
intent is "may diverge in future", document it; otherwise drop the
duplicate field and use `small_font_px` directly.
13. **`src/bubble.rs:1085-1096` — severity: low.** `head_label_rect` and
`head_pct_rect` both use `left: scale_to_dpi(4, dpi)` and `right:
head_diameter - scale_to_dpi(4, dpi)` — identical horizontal extents.
Could share a single `head_text_left`/`head_text_right` pair to make
"head text is centered in the head circle" structurally visible.
14. **`src/bubble.rs:1216 vs 1339-1352` — severity: low.** Stadium
background uses inline two-circle-plus-rect path; the pill helper
does the same shape. The stadium could call `paint_pill(pixmap, 0.0,
0.0, w, h, h/2.0, bg)` and shed ~15 lines. Worth doing once
`paint_pill` moves to a shared module (finding 8).
15. **`src/bubble.rs:1653` — severity: low (text layout).**
`draw_tail_text_in_rect` is called with `DT_RIGHT | DT_VCENTER |
DT_SINGLELINE | DT_END_ELLIPSIS`. The `DT_END_ELLIPSIS` on a
right-aligned 3-char string ("100%") inside a tight rect will produce
`1…` if the rect collapses by even a pixel — fine, but worth
confirming the `pct_reserve_w` (line 1103) leaves a 1-px AA safety
margin. Current `+ scale_to_dpi(2, dpi)` looks adequate. No fix
needed; flag for future locale changes.
16. **`src/bubble.rs:1674-1685` — severity: low.** `draw_text_in_rect`
always uses `DT_NOCLIP`; `draw_tail_text_in_rect` uses
`DT_END_ELLIPSIS` (no `DT_NOCLIP`). The two helpers diverge silently
on whether text may escape its rect. Document the contract on each
helper ("head text trusts layout, tail text fits-or-ellipsises").
17. **`src/panel.rs:447-501` — severity: low.** `draw_text` creates and
destroys a font on every call (4× per `paint`). Same anti-pattern in
`bubble.rs:paint_bubble_text` is amortised by caching `big_font`,
`small_font`, `main_font` for the whole paint. Not a correctness
issue but means the panel allocates 4 GDI fonts on every
InvalidateRect. Cache by `(size, bold)` keyed on the HDC.
## Quick wins
1. **Centralise the neutral palette** (findings 1, 2). One new module
`palette.rs` exposing `bg / track / time_track / time_fill / text /
muted` plus tray-specific tints. Replace all `Color::from_hex(...)`
calls in `bubble.rs:1178-1197`, `bubble.rs:1589-1598`,
`panel.rs:279-293`, and the byte arrays in `tray/badge.rs`. ~20-line
diff, kills cross-surface drift.
2. **Name the padding constants in `compute_bubble_layout`** (finding 3,
9). Add ~6 `const` declarations at the top of the function — keeps
them locally scoped, matches `panel.rs` style. Designer can retune
metrics from one block.
3. **Lift `build_arc` to a shared `tiny_skia_helpers` module** (finding
7). Two-file delete-and-import. Identical behaviour.
4. **Promote `paint_pill` and reuse in `panel.rs` rows + stadium bg**
(findings 8, 14). Makes bar visual style consistent between bubble
and panel; bonus simplification of the stadium fill.
5. **Drop / rename `main_font_px`** (finding 12). Either delete the field
and use `small_font_px` directly, or split the constants (`MAIN_FONT_RATIO`)
so future divergence is intentional.
## Defer
- Finding 4-6 (panel/bubble padding naming, derived `TAIL_RIGHT_INSET`):
worth doing alongside the palette refactor but not blocking.
- Finding 10, 13 (local temporaries for centring math, shared
`head_text_*` extents): pure readability, low payoff.
- Finding 15 (ellipsis safety margin on locale changes): keep an eye on
this when adding a locale wider than `999시간`.
- Finding 17 (panel GDI font caching): only matters if the panel starts
refreshing more often than once per poll cycle.
Out-of-scope sighting (one-line flag as instructed): `compute_bubble_layout`
takes an `HDC` purely to measure text — couples geometry calc to a live GDI
device. Not a render bug, but it makes the function untestable without a
window and is worth refactoring when convenient.
**Status:** DONE
**Summary:** Renderer is functionally solid; the dominant problem is
duplicated/un-named geometry and colour literals scattered across bubble /
panel / badge that drift independently and resist designer-led tuning.
+145 -28
View File
@@ -70,6 +70,9 @@ const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
const IDM_RESTART: u16 = 33;
const IDM_SIZE_SMALLER: u16 = 34;
const IDM_SIZE_LARGER: u16 = 35;
const IDM_RESET_SIZE: u16 = 36;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
@@ -264,6 +267,7 @@ pub fn run(args: crate::AppArgs) {
if let Ok(exe_path) = std::env::current_exe() {
update::handoff::cleanup_stale_old_exes(&exe_path);
}
update::install::cleanup_staged_update_files();
let poll_interval = lock_state()
.as_ref()
@@ -340,8 +344,10 @@ fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
position: settings.bubble_positions.get(kind),
session_pct: None,
session_text: placeholder.clone(),
session_resets_at: None,
weekly_pct: None,
weekly_text: placeholder,
weekly_resets_at: None,
is_dark,
});
if hwnd != HWND::default() {
@@ -401,7 +407,7 @@ fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
}
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
update_settings(|s| s.settings.bubble_size_logical = size_logical);
set_bubble_size(size_logical);
}
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
@@ -418,13 +424,14 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_START_WITH_WINDOWS => toggle_startup(),
IDM_RESET_POSITION => reset_positions(),
IDM_VERSION_ACTION => version_action(),
IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL),
IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL),
IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE),
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
IDM_UPDATE_AUTO_HOURLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
}
IDM_UPDATE_AUTO_DAILY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
}
IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)),
IDM_UPDATE_AUTO_WEEKLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
@@ -636,12 +643,16 @@ fn propagate_to_ui() {
let weekly_text = entry
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at);
let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at);
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
session_text,
session_resets_at,
weekly_pct,
weekly_text,
weekly_resets_at,
);
}
refresh_tray_icons_with(&snap);
@@ -759,14 +770,7 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
} else {
None
},
tooltip: format!(
"{} {}: {} | {}: {}",
snap.i18n_strings.claude_label,
snap.i18n_strings.session_window,
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
snap.i18n_strings.weekly_window,
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
),
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
});
}
if snap.settings.show_codex {
@@ -778,19 +782,27 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
} else {
None
},
tooltip: format!(
"{} {}: {} | {}: {}",
snap.i18n_strings.chatgpt_label,
snap.i18n_strings.session_window,
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
snap.i18n_strings.weekly_window,
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
),
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
});
}
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
}
fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String {
let session = entry
.map(|e| e.primary_text.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("...");
let weekly = entry
.map(|e| e.secondary_text.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("...");
format!(
"{label}\n{}: {session}\n{}: {weekly}\n{}",
strings.session_window, strings.weekly_window, strings.tray_left_click
)
}
fn handle_tray_action(action: TrayAction) {
match action {
TrayAction::None => {}
@@ -926,6 +938,7 @@ struct ContextMenuSnapshot {
widget_visible: bool,
install_channel: InstallChannel,
update_status: UpdateStatus,
bubble_size_logical: i32,
}
fn show_context_menu(owner_hwnd: HWND) {
@@ -945,6 +958,7 @@ fn show_context_menu(owner_hwnd: HWND) {
widget_visible: s.settings.widget_visible,
install_channel: s.install_channel,
update_status: s.update_status,
bubble_size_logical: s.settings.bubble_size_logical,
},
None => return,
};
@@ -986,13 +1000,21 @@ fn show_context_menu(owner_hwnd: HWND) {
models,
IDM_MODEL_CLAUDE,
&snap.strings.claude_label,
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.show_claude {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
models,
IDM_MODEL_CHATGPT,
&snap.strings.chatgpt_label,
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.show_chatgpt {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_submenu(menu, models, &snap.strings.models);
@@ -1005,7 +1027,11 @@ fn show_context_menu(owner_hwnd: HWND) {
settings_menu,
IDM_START_WITH_WINDOWS,
&snap.strings.start_with_windows,
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if is_startup_enabled() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
settings_menu,
@@ -1024,7 +1050,11 @@ fn show_context_menu(owner_hwnd: HWND) {
lang,
IDM_LANG_SYSTEM,
&snap.strings.system_default,
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.language_override.is_none() {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
for (i, (code, name)) in snap.available.iter().enumerate() {
let id = IDM_LANG_BASE + i as u16;
@@ -1052,7 +1082,12 @@ fn show_context_menu(owner_hwnd: HWND) {
} else {
MENU_ITEM_FLAGS(0)
};
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
append_item(
settings_menu,
IDM_VERSION_ACTION,
&version_label,
version_flags,
);
let Ok(auto_update) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(auto_update) failed");
@@ -1088,11 +1123,58 @@ fn show_context_menu(owner_hwnd: HWND) {
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
append_submenu(menu, settings_menu, &snap.strings.settings);
let Ok(controls) = CreatePopupMenu() else {
log::error!("CreatePopupMenu(controls) failed");
let _ = DestroyMenu(menu);
return;
};
append_item(
controls,
IDM_SIZE_SMALLER,
&snap.strings.size_smaller,
if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
controls,
IDM_SIZE_LARGER,
&snap.strings.size_larger,
if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
},
);
append_item(
controls,
IDM_RESET_SIZE,
&snap.strings.reset_size,
if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE {
MF_GRAYED
} else {
MENU_ITEM_FLAGS(0)
},
);
let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null());
append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED);
append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED);
append_submenu(menu, controls, &snap.strings.controls);
append_item(
menu,
tray::IDM_TOGGLE_WIDGET,
&snap.strings.show_widget,
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
if snap.widget_visible {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
},
);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
@@ -1116,7 +1198,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
let w = os::to_utf16_nul(label);
unsafe {
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
let _ = AppendMenuW(
menu,
MF_POPUP,
submenu.0 as usize,
PCWSTR::from_raw(w.as_ptr()),
);
}
}
@@ -1181,7 +1268,9 @@ fn toggle_model(model: ProviderId) {
ProviderId::Claude => settings.show_claude_code,
ProviderId::ChatGpt => settings.show_codex,
};
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
let existing = lock_state()
.as_ref()
.and_then(|s| s.bubbles.get(&model).copied());
match (want, existing) {
(true, None) => spawn_bubble(model, &settings, is_dark),
(false, Some(h)) => {
@@ -1244,6 +1333,34 @@ fn reset_positions() {
spawn_poll_thread();
}
fn resize_bubbles(delta: i32) {
let current = lock_state()
.as_ref()
.map(|s| s.settings.bubble_size_logical)
.unwrap_or(bubble::DEFAULT_BUBBLE_SIZE);
set_bubble_size(current + delta);
}
fn set_bubble_size(size_logical: i32) {
let (hwnds, snap) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE);
if new_size == s.settings.bubble_size_logical {
return;
}
s.settings.bubble_size_logical = new_size;
let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::<Vec<_>>();
(hwnds, s.settings.clone())
};
settings::save(&snap);
for hwnd in hwnds {
bubble::set_size_logical(hwnd, snap.bubble_size_logical);
}
}
fn set_language(_dummy: Option<()>) {
update_settings(|s| {
s.i18n.set_active(None);
+890 -405
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"
);
}
}
+2 -1
View File
@@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{
/// so no zombie wait is required.
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let mut cmdline = build_command_line(exe, args);
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
@@ -32,7 +33,7 @@ pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
let ok = unsafe {
CreateProcessW(
PCWSTR::null(),
PCWSTR::from_raw(exe_w.as_ptr()),
windows::core::PWSTR(cmdline.as_mut_ptr()),
None,
None,
+148 -55
View File
@@ -1,10 +1,10 @@
// Download a release asset and swap it in via native Win32 calls.
// Download a release asset and swap it in via a detached helper process.
//
// After writing the new .exe to a staging path and verifying its
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows
// releases the file lock on our own image), then `MoveFileExW` the
// staged exe into place, then spawn the new binary detached via
// `handoff::spawn_detached`. No shell, no console allocation.
// The running process cannot reliably replace its own mapped image on
// Windows. Instead, it writes the new .exe to a staging path, copies the
// current executable to a helper path, starts that helper with
// `--apply-update`, and then exits. The helper waits for the parent process
// to exit before replacing the install target with the staged new binary.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -16,9 +16,7 @@ use windows::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
};
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{
MessageBoxW, MB_ICONERROR, MB_OK,
};
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
use crate::net::Client;
use crate::os::to_utf16_nul;
@@ -35,20 +33,46 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?;
}
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
swap_and_spawn(&staging, &current, &release.version)?;
download(
http,
&release.asset_url,
&staging,
release.asset_sha256.as_ref(),
)?;
let helper = helper_path()?;
reject_unsafe_path(&helper)?;
prepare_update_helper(&current, &helper)?;
spawn_update_helper(&helper, &staging, &current, &release.version)?;
Ok(())
}
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
/// The native handoff already does the swap-and-restart; if this binary
/// is invoked with the legacy flag (e.g. from an older release's helper)
/// just exit cleanly so the upgrade still completes.
/// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
/// Runs from the staged new binary, waits for the old UI process to exit,
/// replaces the installed exe, and starts the installed copy.
pub fn run_cli(args: &[String]) -> Option<i32> {
if args.len() >= 2 && args[1] == "--apply-update" {
Some(0)
} else {
None
if args.get(1).map(String::as_str) != Some("--apply-update") {
return None;
}
let Some(target) = args.get(2).map(PathBuf::from) else {
return Some(2);
};
let Some(source) = args.get(3).map(PathBuf::from) else {
return Some(2);
};
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
return Some(2);
};
let Some(version) = args.get(5).cloned() else {
return Some(2);
};
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
match replace_from_helper(&source, &target, &version) {
Ok(()) => Some(0),
Err(e) => {
log::error!("apply-update failed: {e}");
Some(1)
}
}
}
@@ -63,7 +87,9 @@ fn download(
.header("User-Agent", super::release::user_agent())
.send()?;
if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
return Err(super::Error::Network(crate::net::Error::Status(
resp.status(),
)));
}
let body = resp.body();
if let Some(expected) = expected_sha256 {
@@ -94,61 +120,68 @@ fn hex_encode(bytes: &[u8]) -> String {
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
let s = p.to_string_lossy();
if s.contains('%') {
return Err(super::Error::UnsafePath(format!(
"path contains '%': {s}"
)));
return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
}
Ok(())
}
fn swap_and_spawn(
source: &Path,
fn spawn_update_helper(
helper: &Path,
staging: &Path,
target: &Path,
version: &super::release::Version,
) -> Result<(), super::Error> {
let backup = backup_path(target);
// Step 1: rename running exe sideways. Windows allows renaming a
// file even while its image is mapped into memory; this releases
// the lock on the original `target` path. Same directory by
// construction, so plain MoveFileExW with no flags is sufficient.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
// Step 2: move staged exe into place. Staging lives under
// %LOCALAPPDATA%, target lives wherever the user installed —
// COPY_ALLOWED lets MoveFileExW fall back to copy+delete when
// the two paths cross volumes (portable installs on D:/E:/etc.).
let step2_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, step2_flags) {
// Best-effort revert. Same volume, no COPY_ALLOWED needed.
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(swap_err);
}
// Step 3: spawn the new exe detached with --wait-pid + --updated-to.
let pid = unsafe { GetCurrentProcessId() };
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
let args = vec![
OsString::from("--wait-pid"),
OsString::from("--apply-update"),
target.as_os_str().to_os_string(),
staging.as_os_str().to_os_string(),
OsString::from(pid.to_string()),
OsString::from("--updated-to"),
OsString::from(version_str),
];
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
}
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
let backup = backup_path(target);
// Parent has exited, so the install target is no longer mapped.
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
if let Err(swap_err) = move_file(source, target, swap_flags) {
// Compatibility for users updating from a release that invoked
// the downloaded binary itself as the helper. A mapped source exe
// may not be movable, but it can usually still be copied.
let copy_result = std::fs::copy(source, target);
if copy_result.is_err() {
log::error!("source move failed before copy fallback: {swap_err}");
}
if let Err(copy_err) = copy_result {
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("rollback also failed: {revert_err}; surfacing modal");
let target_name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
surface_rollback_failure(&backup, &target_name);
}
return Err(super::Error::Io(copy_err));
}
}
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
// New binary is on disk but won't auto-launch. Roll back so
// the user's next "Restart" stays on the known-good version.
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
let _ = std::fs::remove_file(target);
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
log::error!("post-spawn revert failed: {revert_err}");
}
return Err(super::Error::Io(spawn_err));
}
let _ = std::fs::remove_file(source);
Ok(())
}
@@ -214,6 +247,47 @@ fn stage_path() -> Result<PathBuf, super::Error> {
.join("update.exe"))
}
fn helper_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
let pid = unsafe { GetCurrentProcessId() };
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join(format!("updater-helper-{pid}.exe")))
}
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
if let Some(parent) = helper.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(current, helper)?;
Ok(())
}
pub fn cleanup_staged_update_files() {
let Ok(stage) = stage_path() else {
return;
};
let Some(dir) = stage.parent() else {
return;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
if let Err(e) = std::fs::remove_file(&path) {
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
}
}
}
}
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let parent = target.parent().ok_or_else(|| {
super::Error::NotWritable("could not resolve install directory".to_string())
@@ -223,3 +297,22 @@ fn ensure_writable(target: &Path) -> Result<(), super::Error> {
let _ = std::fs::remove_file(&probe);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_and_helper_paths_are_distinct_exes() {
let stage = stage_path().expect("stage path");
let helper = helper_path().expect("helper path");
assert_eq!(stage.file_name().unwrap(), "update.exe");
assert!(helper
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("updater-helper-"));
assert_ne!(stage, helper);
}
}
+15 -8
View File
@@ -5,20 +5,27 @@
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 {
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
/// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
/// near-black `#1A1A1A` on light — so the accent stays readable against both
/// the dark bubble surface and the `#F3F3F3` light background.
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"),
ProviderId::ChatGpt => {
if is_dark {
Color::from_hex("#E5E5E5")
} else {
Color::from_hex("#1A1A1A")
}
}
}
}
/// 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.
/// color so Codex bars render monochrome (light-on-dark / dark-on-light) 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)