feat(bubble): apply full UX review — labels, inline percent, accent, snaps

Implements every recommendation from the UI/UX review of the two-bar
bubble: readability bump, faster glanceability, per-provider identity,
smarter resize curve, and a richer snap behavior set.

Readability + sizing:
- Default width 180 → 200, min 120 → 140 (Windows shell minimum legible
  status-text floor is 12 px; previous breakpoint produced 11 px text)
- Discrete breakpoint table replaces the linear bar_h = h/4 formula —
  140/200/280/360 widths map to (bar_h, font, row_gap) of
  (12,11,4)/(16,13,6)/(20,15,8)/(24,17,10)
- Aspect tapers 3:1 → 2.8:1 → 2.6:1 as width grows so bars stay
  proportionally chunky at large sizes
- Right-column width now derived from GetTextExtentPoint32W of
  "100% · 23h" against the real font instead of the bar_h * 6 heuristic

Glanceability:
- ring_color_for_percent rewritten as a 4-band cliff: orange < 60,
  amber 60-80, red 80-95, deep red ≥ 95 — gradient hid the 75 vs 85
  difference at narrow bar widths
- Percent moved inside the bar (right-aligned with auto-contrast picked
  from luminance of the underlying fill or track)
- Right column now shows only the countdown ("2h 14m"), not the
  combined "X% · 2h 14m" string. i18n::format_countdown is now pub so
  the bubble can request countdown-only; the panel still renders the
  combined string via format_window
- "5h" / "7d" muted labels added in a new left column
- Rows whose bar is ≥ 95 % get a 15 %-red blush behind the entire row;
  fill color also brightens via a slow triangle wave (TIMER_PULSE,
  80 ms, only armed when at least one bar is in the alarm band)

Per-provider identity:
- 4-px vertical accent stripe at the left edge — Claude orange
  (#D97757), Codex green (#10A37F) — disambiguates the two bubbles
  when both are enabled. Mirrored into the expanded panel so the
  identity carries across surfaces

Snap behavior:
- Corner snap: release within 32 px of a work-area corner slams the
  bubble into the corner with a 12-px inset
- Taskbar-adjacency: SHAppBarMessage(ABM_GETTASKBARPOS) supplies the
  taskbar rect/edge; bubble docks against the inner face with a
  4-px gap on the docked edge
- Peer-Y alignment: when a second bubble exists within ±8 px on Y
  at release time, the dragged bubble snaps to share its top edge
- WM_SETTINGCHANGE handler re-clamps bubbles into the new work area
  when taskbar moves / auto-hides / DPI changes

Color packing:
- Direct DIB writes use rgb_to_dib (B,G,R,X memory order) instead of
  into_colorref (R,G,B,X COLORREF order). Already corrected in the
  previous redesign for the bar fills, but the accent stripe and blush
  go through the same path now
This commit is contained in:
2026-05-16 11:35:02 +07:00
parent ee4e1c9b26
commit 5df75c901e
4 changed files with 704 additions and 156 deletions
+9 -2
View File
@@ -502,8 +502,15 @@ fn propagate_to_ui() {
let entry = snap.snapshots.get(&id);
let session_pct = entry.map(|s| s.windows.primary.utilization);
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
let session_text = entry.map(|s| s.primary_text.clone()).unwrap_or_default();
let weekly_text = entry.map(|s| s.secondary_text.clone()).unwrap_or_default();
// The bubble paints the percent inline inside the bar fill, so it
// only needs the countdown string on the right. The panel still
// shows the combined "X% · Yh" string via `primary_text`.
let session_text = entry
.map(|s| i18n::format_countdown(s.windows.primary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
let weekly_text = entry
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
.unwrap_or_default();
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
+675 -153
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -198,7 +198,9 @@ pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) ->
}
}
fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
/// Countdown only — used by the bubble, which renders the percent inside the
/// bar fill and only needs the time-to-reset on the right.
pub fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
let Some(reset) = resets_at else {
return String::new();
};
+17
View File
@@ -276,6 +276,23 @@ fn paint(hwnd: HWND, hdc: HDC) {
FillRect(hdc, &rc, bg_brush);
let _ = DeleteObject(bg_brush);
// 4-px accent stripe matching the bubble — same provider color so the
// identity carries across both surfaces.
let stripe_color = match data.model {
ProviderId::Claude => Color::from_hex("#D97757"),
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
};
let stripe_w = scaled(4);
let stripe_rect = RECT {
left: 0,
top: 0,
right: stripe_w,
bottom: rc.bottom,
};
let stripe_brush = CreateSolidBrush(COLORREF(stripe_color.into_colorref()));
FillRect(hdc, &stripe_rect, stripe_brush);
let _ = DeleteObject(stripe_brush);
// Header row: model label
let header = match data.model {
ProviderId::Claude => data.strings.claude_label.clone(),