Compare commits

...
7 Commits
Author SHA1 Message Date
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
8 changed files with 491 additions and 52 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.2.0"
version = "0.3.4"
dependencies = [
"dirs",
"embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.2.0"
version = "0.3.4"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
@@ -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?
+6
View File
@@ -343,8 +343,10 @@ fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
position: settings.bubble_positions.get(kind),
session_pct: None,
session_text: placeholder.clone(),
session_resets_at: None,
weekly_pct: None,
weekly_text: placeholder,
weekly_resets_at: None,
is_dark,
});
if hwnd != HWND::default() {
@@ -640,12 +642,16 @@ fn propagate_to_ui() {
let weekly_text = entry
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at);
let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at);
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
session_text,
session_resets_at,
weekly_pct,
weekly_text,
weekly_resets_at,
);
}
refresh_tray_icons_with(&snap);
+255 -50
View File
@@ -2,8 +2,8 @@
//
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
// The shape is a stadium (rounded-rect with corner_radius = height/2). The left
// half is the "head" — a stroked progress ring around the 5h percentage glyph.
// The right half is the "tail" — small "7d" label, thin progress bar, countdown.
// half is the "head" — usage and remaining-time rings around the 5h percentage
// glyph. The right half is the "tail" — weekly usage and remaining-time bars.
//
// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc)
// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB;
@@ -14,6 +14,7 @@
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, SystemTime};
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
use windows::core::PCWSTR;
@@ -32,7 +33,9 @@ use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
const TIMER_FULLSCREEN_CHECK: usize = 5;
const TIMER_PULSE: usize = 6;
const TIMER_TIME_PROGRESS: usize = 7;
const PULSE_INTERVAL_MS: u32 = 80;
const TIME_PROGRESS_INTERVAL_MS: u32 = 60_000;
use crate::usage::ProviderId;
// ---------- Public types & API ----------
@@ -52,6 +55,8 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
@@ -72,8 +77,10 @@ pub struct BubbleConfig {
pub position: Option<(i32, i32)>,
pub session_pct: Option<f64>,
pub session_text: String,
pub session_resets_at: Option<SystemTime>,
pub weekly_pct: Option<f64>,
pub weekly_text: String,
pub weekly_resets_at: Option<SystemTime>,
pub is_dark: bool,
}
@@ -82,6 +89,31 @@ fn bubble_height_logical(width_logical: i32) -> i32 {
((width_logical * den) / num).max(20)
}
#[derive(Clone, Copy)]
enum UsageWindowKind {
Primary,
Secondary,
}
fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 {
// Claude exposes 5h/7d directly. Codex exposes primary/secondary fields;
// the product maps those to the same short/long windows in the compact UI.
match (model, window) {
(ProviderId::Claude, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
(ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
(ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
}
}
fn remaining_fraction(resets_at: Option<SystemTime>, duration_secs: u64) -> Option<f32> {
let reset = resets_at?;
let remaining = reset
.duration_since(SystemTime::now())
.unwrap_or_else(|_| Duration::from_secs(0));
Some((remaining.as_secs_f64() / duration_secs as f64).clamp(0.0, 1.0) as f32)
}
/// Owner-supplied event callbacks. The bubble window proc is a leaf — it
/// doesn't know about `app`. The owner installs these once at startup so the
/// proc can dispatch UI events back without an upward `crate::app::` reach.
@@ -203,14 +235,17 @@ pub fn create(config: BubbleConfig) -> HWND {
dpi,
session_pct: config.session_pct,
session_text: config.session_text,
session_resets_at: config.session_resets_at,
weekly_pct: config.weekly_pct,
weekly_text: config.weekly_text,
weekly_resets_at: config.weekly_resets_at,
is_dark: config.is_dark,
drag_start_pos: None,
hidden_by_fullscreen: false,
user_hidden: false,
pulse_phase: 0,
pulse_timer_armed: false,
time_progress_timer_armed: false,
},
);
@@ -238,6 +273,7 @@ pub fn destroy(hwnd: HWND) {
unsafe {
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
let _ = KillTimer(hwnd, TIMER_PULSE);
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
let _ = DestroyWindow(hwnd);
}
}
@@ -273,8 +309,10 @@ pub fn update_data(
hwnd: HWND,
session_pct: Option<f64>,
session_text: String,
session_resets_at: Option<SystemTime>,
weekly_pct: Option<f64>,
weekly_text: String,
weekly_resets_at: Option<SystemTime>,
) {
{
let mut bubbles = lock_bubbles();
@@ -283,10 +321,13 @@ pub fn update_data(
};
b.session_pct = session_pct;
b.session_text = session_text;
b.session_resets_at = session_resets_at;
b.weekly_pct = weekly_pct;
b.weekly_text = weekly_text;
b.weekly_resets_at = weekly_resets_at;
}
sync_pulse_timer(hwnd);
sync_time_progress_timer(hwnd);
render(hwnd);
}
@@ -320,6 +361,32 @@ fn sync_pulse_timer(hwnd: HWND) {
}
}
fn sync_time_progress_timer(hwnd: HWND) {
let (should_be_armed, currently_armed) = {
let bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
return;
};
(
b.session_resets_at.is_some() || b.weekly_resets_at.is_some(),
b.time_progress_timer_armed,
)
};
if should_be_armed == currently_armed {
return;
}
unsafe {
if should_be_armed {
SetTimer(hwnd, TIMER_TIME_PROGRESS, TIME_PROGRESS_INTERVAL_MS, None);
} else {
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
}
}
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
b.time_progress_timer_armed = should_be_armed;
}
}
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
{
let mut bubbles = lock_bubbles();
@@ -381,8 +448,10 @@ struct BubbleState {
dpi: u32,
session_pct: Option<f64>,
session_text: String,
session_resets_at: Option<SystemTime>,
weekly_pct: Option<f64>,
weekly_text: String,
weekly_resets_at: Option<SystemTime>,
is_dark: bool,
drag_start_pos: Option<(i32, i32)>,
hidden_by_fullscreen: bool,
@@ -392,6 +461,8 @@ struct BubbleState {
pulse_phase: u32,
/// Whether TIMER_PULSE is currently armed for this bubble.
pulse_timer_armed: bool,
/// Whether TIMER_TIME_PROGRESS is armed to keep reset-time visuals current.
time_progress_timer_armed: bool,
}
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
@@ -505,6 +576,7 @@ unsafe extern "system" fn wnd_proc(
}
render(hwnd);
}
w if w == TIMER_TIME_PROGRESS => render(hwnd),
_ => {}
}
LRESULT(0)
@@ -961,7 +1033,7 @@ const COUNTDOWN_TEMPLATE: &str = "999시간";
///
/// The outline is a stadium (rounded rect with `corner_radius = canvas_h / 2`).
/// The left `head_diameter × canvas_h` square holds the 5h progress ring + big
/// percent glyph. The rest is the tail: 7d label, thin bar, countdown.
/// percent glyph. The rest is the tail: weekly usage lane + reset-time lane.
struct BubbleLayout {
canvas_w: i32,
canvas_h: i32,
@@ -971,11 +1043,14 @@ struct BubbleLayout {
ring_cy: f32,
ring_radius: f32,
ring_stroke_w: f32,
time_ring_radius: f32,
time_ring_stroke_w: f32,
head_label_rect: RECT,
head_pct_rect: RECT,
tail_label_rect: RECT,
tail_bar_rect: RECT,
tail_countdown_rect: RECT,
tail_usage_pct_rect: RECT,
tail_usage_bar_rect: RECT,
tail_time_text_rect: RECT,
tail_time_bar_rect: RECT,
big_font_px: i32,
small_font_px: i32,
main_font_px: i32,
@@ -994,6 +1069,9 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
// inside the head padding. ring_radius is the centerline radius.
let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32);
let ring_radius = ring_outer - ring_stroke_w / 2.0;
let time_ring_stroke_w = scale_to_dpi(2, dpi).clamp(1, 3) as f32;
let time_ring_radius =
(ring_radius - ring_stroke_w - scale_to_dpi(3, dpi) as f32).max(time_ring_stroke_w);
let big_font_px = (head_diameter * 26 / 100).max(scale_to_dpi(11, dpi));
let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi));
@@ -1022,16 +1100,30 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
let pad = scale_to_dpi(6, dpi);
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px);
let label_w = measure_text_w(mem_dc, "7d", small_font_px);
let pct_reserve_w = measure_text_w(mem_dc, "100%", small_font_px) + scale_to_dpi(2, dpi);
let tail_label_left = tail_left + pad;
let tail_label_right = tail_label_left + label_w;
let tail_countdown_right = tail_right;
let tail_countdown_left = tail_countdown_right - countdown_w;
let tail_bar_left = tail_label_right + pad;
let tail_bar_right = (tail_countdown_left - pad).max(tail_bar_left + scale_to_dpi(20, dpi));
let tail_bar_h = scale_to_dpi(5, dpi);
let tail_bar_top = (height_px - tail_bar_h) / 2;
let usage_bar_h = (height_px * 9 / 100).clamp(scale_to_dpi(5, dpi), scale_to_dpi(12, dpi));
let time_bar_h = (height_px * 5 / 100).clamp(scale_to_dpi(3, dpi), scale_to_dpi(7, dpi));
let lane_gap = scale_to_dpi(5, dpi);
let lanes_h = usage_bar_h + lane_gap + time_bar_h;
let usage_bar_top = (height_px - lanes_h) / 2;
let time_bar_top = usage_bar_top + usage_bar_h + lane_gap;
let time_text_h = main_font_px + scale_to_dpi(2, dpi);
let usage_pct_h = small_font_px + scale_to_dpi(2, dpi);
let content_left = tail_left + pad;
let content_right = tail_right;
let content_w = (content_right - content_left).max(0);
let bar_min = scale_to_dpi(8, dpi);
let desired_text_w = countdown_w.max(pct_reserve_w);
let text_w = if content_w >= desired_text_w + pad + bar_min {
desired_text_w
} else {
(content_w - pad - bar_min).max(0)
};
let text_left = content_right - text_w;
let bar_left = content_left;
let bar_right = (text_left - pad).max(bar_left + bar_min);
BubbleLayout {
canvas_w: width_px,
@@ -1042,25 +1134,33 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
ring_cy,
ring_radius,
ring_stroke_w,
time_ring_radius,
time_ring_stroke_w,
head_label_rect,
head_pct_rect,
tail_label_rect: RECT {
left: tail_label_left,
top: 0,
right: tail_label_right,
bottom: height_px,
tail_usage_pct_rect: RECT {
left: text_left,
top: usage_bar_top + (usage_bar_h - usage_pct_h) / 2,
right: content_right,
bottom: usage_bar_top + (usage_bar_h - usage_pct_h) / 2 + usage_pct_h,
},
tail_bar_rect: RECT {
left: tail_bar_left,
top: tail_bar_top,
right: tail_bar_right,
bottom: tail_bar_top + tail_bar_h,
tail_usage_bar_rect: RECT {
left: bar_left,
top: usage_bar_top,
right: bar_right,
bottom: usage_bar_top + usage_bar_h,
},
tail_countdown_rect: RECT {
left: tail_countdown_left,
top: 0,
right: tail_countdown_right,
bottom: height_px,
tail_time_text_rect: RECT {
left: text_left,
top: time_bar_top + (time_bar_h - time_text_h) / 2,
right: content_right,
bottom: time_bar_top + (time_bar_h - time_text_h) / 2 + time_text_h,
},
tail_time_bar_rect: RECT {
left: bar_left,
top: time_bar_top,
right: bar_right,
bottom: time_bar_top + time_bar_h,
},
big_font_px,
small_font_px,
@@ -1085,6 +1185,16 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
} else {
Color::from_hex("#D6D6D6")
};
let time_track = if inputs.is_dark {
Color::from_hex("#303030")
} else {
Color::from_hex("#E0E0E0")
};
let time_fill = if inputs.is_dark {
Color::from_hex("#9A9A9A")
} else {
Color::from_hex("#777777")
};
// ---- Stadium background ----
{
@@ -1144,14 +1254,45 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
}
}
}
// Inner ring: true remaining time for the 5h/primary window. This
// stays neutral so it reads as time, not another quota alarm.
let mut paint = Paint::default();
paint.set_color(rgb_to_skia(time_track));
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = layout.time_ring_stroke_w;
let mut pb = PathBuilder::new();
pb.push_circle(layout.ring_cx, layout.ring_cy, layout.time_ring_radius);
if let Some(p) = pb.finish() {
pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None);
}
if let Some(frac) = remaining_fraction(
inputs.session_resets_at,
window_duration_secs(inputs.model, UsageWindowKind::Primary),
) {
if frac > 0.0 {
let mut paint = Paint::default();
paint.set_color(rgb_to_skia(time_fill));
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = layout.time_ring_stroke_w;
stroke.line_cap = LineCap::Round;
if let Some(path) =
build_arc(layout.ring_cx, layout.ring_cy, layout.time_ring_radius, frac)
{
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
}
}
// ---- Tail bar (7d) ----
// ---- Tail usage bar + reset-time bar ----
{
let bar_x = layout.tail_bar_rect.left as f32;
let bar_y = layout.tail_bar_rect.top as f32;
let bar_w = (layout.tail_bar_rect.right - layout.tail_bar_rect.left) as f32;
let bar_h = (layout.tail_bar_rect.bottom - layout.tail_bar_rect.top) as f32;
let bar_x = layout.tail_usage_bar_rect.left as f32;
let bar_y = layout.tail_usage_bar_rect.top as f32;
let bar_w = (layout.tail_usage_bar_rect.right - layout.tail_usage_bar_rect.left) as f32;
let bar_h = (layout.tail_usage_bar_rect.bottom - layout.tail_usage_bar_rect.top) as f32;
let cap = bar_h * 0.5;
if bar_w > 0.0 && bar_h > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, track);
@@ -1170,13 +1311,31 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
}
}
}
let bar_x = layout.tail_time_bar_rect.left as f32;
let bar_y = layout.tail_time_bar_rect.top as f32;
let bar_w = (layout.tail_time_bar_rect.right - layout.tail_time_bar_rect.left) as f32;
let bar_h = (layout.tail_time_bar_rect.bottom - layout.tail_time_bar_rect.top) as f32;
let cap = bar_h * 0.5;
if bar_w > 0.0 && bar_h > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, time_track);
if let Some(frac) = remaining_fraction(
inputs.weekly_resets_at,
window_duration_secs(inputs.model, UsageWindowKind::Secondary),
) {
let fill_w = bar_w * frac;
if fill_w > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, fill_w.min(bar_w), bar_h, cap, time_fill);
}
}
}
}
Some(pixmap)
}
/// Fill a horizontal pill at `(x, y, w, h)` with circular end caps of radius
/// `cap`. Used for both the track and the fill of the tail's 7d bar.
/// `cap`. Used for both track and fill segments in the tail bars.
fn paint_pill(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, cap: f32, color: Color) {
let mut paint = Paint::default();
paint.set_color(rgb_to_skia(color));
@@ -1280,8 +1439,10 @@ struct PaintInputs {
model: ProviderId,
session_pct: Option<f64>,
session_text: String,
session_resets_at: Option<SystemTime>,
weekly_pct: Option<f64>,
weekly_text: String,
weekly_resets_at: Option<SystemTime>,
is_dark: bool,
pulse_phase: u32,
}
@@ -1299,8 +1460,10 @@ fn render(hwnd: HWND) {
model: b.model,
session_pct: b.session_pct,
session_text: b.session_text.clone(),
session_resets_at: b.session_resets_at,
weekly_pct: b.weekly_pct,
weekly_text: b.weekly_text.clone(),
weekly_resets_at: b.weekly_resets_at,
is_dark: b.is_dark,
pulse_phase: b.pulse_phase,
},
@@ -1420,8 +1583,8 @@ fn brighten(c: Color, t: f64) -> Color {
)
}
/// Paint the new bubble's text overlay via GDI: small "5h" label + big "%"
/// glyph in the head, small "7d" label + countdown on the tail.
/// Paint the bubble's text overlay via GDI: primary countdown + big "%"
/// glyph in the head, weekly percent + weekly countdown on the tail.
fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
let text_color = if inputs.is_dark {
Color::from_hex("#EAEAEA")
@@ -1443,9 +1606,24 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
let prev_font = SelectObject(hdc, small_font);
// Head: "5h" label (muted, centered horizontally).
// Head: 5h countdown text if available, otherwise the static "5h" tag.
// The ring already signals "this is the 5h window", so the countdown
// is the more useful glanceable info when we have it. Fall back to
// "5h" when the localized countdown would overflow the rect (e.g.,
// wide CJK strings like "999시간" at the 140-logical minimum width) —
// DT_NOCLIP would otherwise leak the glyphs onto the ring stroke.
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_text_in_rect(hdc, &layout.head_label_rect, "5h", DT_CENTER);
let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left;
let head_label_text: &str = if inputs.session_text.is_empty() {
"5h"
} else if measure_text_w(hdc, &inputs.session_text, layout.small_font_px)
<= head_label_rect_w
{
inputs.session_text.as_str()
} else {
"5h"
};
draw_text_in_rect(hdc, &layout.head_label_rect, head_label_text, DT_CENTER);
// Head: big "X%" glyph centered.
SelectObject(hdc, big_font);
@@ -1456,21 +1634,31 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
};
draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER);
// Tail: "7d" label (muted, left-aligned).
// Tail: weekly percent (foreground color, right of its usage bar). Skipped
// when the layout collapsed the rect at small widths. Foreground —
// not the accent color the bar uses — because Codex teal #10A37F on
// the light theme background only hits ~3.2:1 contrast, below WCAG
// AA for small text. Adjacency to the bar carries the visual
// grouping; we don't need hue to do it too.
SelectObject(hdc, small_font);
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT);
if let Some(pct) = inputs.weekly_pct {
if layout.tail_usage_pct_rect.right > layout.tail_usage_pct_rect.left {
let mut color = text_color;
if pct >= 95.0 {
let t = pulse_triangle(inputs.pulse_phase);
color = brighten(color, t);
}
SetTextColor(hdc, COLORREF(color.into_colorref()));
let weekly_pct_text = format!("{:.0}%", pct);
draw_tail_text_in_rect(hdc, &layout.tail_usage_pct_rect, &weekly_pct_text, DT_RIGHT);
}
}
// Tail: countdown (right-aligned).
// Tail: weekly countdown aligned with its true remaining-time bar.
SelectObject(hdc, main_font);
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
if !inputs.weekly_text.is_empty() {
draw_text_in_rect(
hdc,
&layout.tail_countdown_rect,
&inputs.weekly_text,
DT_RIGHT,
);
draw_tail_text_in_rect(hdc, &layout.tail_time_text_rect, &inputs.weekly_text, DT_RIGHT);
}
SelectObject(hdc, prev_font);
@@ -1497,6 +1685,23 @@ fn draw_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT
}
}
fn draw_tail_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT) {
if rect.right <= rect.left {
return;
}
let mut buf = wide_str(text);
let len_no_nul = buf.len().saturating_sub(1);
let mut r = *rect;
unsafe {
let _ = DrawTextW(
hdc,
&mut buf[..len_no_nul],
&mut r,
halign | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS,
);
}
}
fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT {
unsafe {
CreateFontW(