mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 18:17:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b31d3211 | ||
|
|
5c2b14fc03 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.3.2"
|
||||
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?
|
||||
@@ -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);
|
||||
|
||||
+220
-79
@@ -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,12 +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_pct_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,
|
||||
@@ -995,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));
|
||||
@@ -1023,39 +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 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);
|
||||
|
||||
// Try to seat a 7d% text between the "7d" label and the bar. The %
|
||||
// number is the actual data, so it takes precedence over keeping the
|
||||
// bar at its pre-feature minimum: when the % shows, the bar may
|
||||
// compress to `bar_min_with_pct` (still visible as a pill). Only when
|
||||
// even that small bar wouldn't fit do we collapse the % rect entirely
|
||||
// and restore the pre-feature `bar_min` to keep the bar readable.
|
||||
// Without this two-tier threshold, the worst-case CJK countdown column
|
||||
// ("999시간") leaves <20 logical of bar room at the default 200-logical
|
||||
// size on both 100% and 125% DPI, and the % silently disappears.
|
||||
let bar_min = scale_to_dpi(20, dpi);
|
||||
let bar_min_with_pct = scale_to_dpi(8, dpi);
|
||||
let (tail_pct_left, tail_pct_right, tail_bar_left, bar_render_min) = {
|
||||
let pct_left = tail_label_right + pad;
|
||||
let pct_right = pct_left + pct_reserve_w;
|
||||
let bar_left = pct_right + pad;
|
||||
if (tail_countdown_left - pad) - bar_left >= bar_min_with_pct {
|
||||
(pct_left, pct_right, bar_left, bar_min_with_pct)
|
||||
} else {
|
||||
let bar_left = tail_label_right + pad;
|
||||
(bar_left, bar_left, bar_left, bar_min)
|
||||
}
|
||||
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 tail_bar_right = (tail_countdown_left - pad).max(tail_bar_left + bar_render_min);
|
||||
let tail_bar_h = (height_px * 9 / 100).clamp(scale_to_dpi(5, dpi), scale_to_dpi(12, dpi));
|
||||
let tail_bar_top = (height_px - tail_bar_h) / 2;
|
||||
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,
|
||||
@@ -1066,31 +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_pct_rect: RECT {
|
||||
left: tail_pct_left,
|
||||
top: 0,
|
||||
right: tail_pct_right,
|
||||
bottom: height_px,
|
||||
tail_usage_bar_rect: RECT {
|
||||
left: bar_left,
|
||||
top: usage_bar_top,
|
||||
right: bar_right,
|
||||
bottom: usage_bar_top + usage_bar_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_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_countdown_rect: RECT {
|
||||
left: tail_countdown_left,
|
||||
top: 0,
|
||||
right: tail_countdown_right,
|
||||
bottom: height_px,
|
||||
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,
|
||||
@@ -1115,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 ----
|
||||
{
|
||||
@@ -1174,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);
|
||||
@@ -1200,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));
|
||||
@@ -1310,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,
|
||||
}
|
||||
@@ -1329,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,
|
||||
},
|
||||
@@ -1450,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")
|
||||
@@ -1501,19 +1634,15 @@ 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).
|
||||
SelectObject(hdc, small_font);
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT);
|
||||
|
||||
// Tail: 7d percent (foreground color, between label and bar). Skipped
|
||||
// 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);
|
||||
if let Some(pct) = inputs.weekly_pct {
|
||||
if layout.tail_pct_rect.right > layout.tail_pct_rect.left {
|
||||
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);
|
||||
@@ -1521,20 +1650,15 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
}
|
||||
SetTextColor(hdc, COLORREF(color.into_colorref()));
|
||||
let weekly_pct_text = format!("{:.0}%", pct);
|
||||
draw_text_in_rect(hdc, &layout.tail_pct_rect, &weekly_pct_text, DT_CENTER);
|
||||
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);
|
||||
@@ -1561,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(
|
||||
|
||||
Reference in New Issue
Block a user