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).
This commit is contained in:
2026-05-23 23:36:34 +07:00
parent f2b31d3211
commit 5976181cb2
3 changed files with 453 additions and 24 deletions
+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.
+45 -24
View File
@@ -1061,7 +1061,7 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi);
let head_diameter = height_px;
let head_pad = scale_to_dpi(4, dpi);
let head_pad = scale_to_dpi(5, dpi);
let ring_stroke_w = scale_to_dpi(3, dpi).clamp(2, 4) as f32;
let ring_cx = (head_diameter as f32) / 2.0;
let ring_cy = (height_px as f32) / 2.0;
@@ -1069,11 +1069,14 @@ 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;
// Inner ring renders the remaining-time arc. Floor stroke at 2 logical so
// it stays visible at smaller bubble sizes (clamp 1 produced a hairline
// that disappeared into the track on dark themes).
let time_ring_stroke_w = scale_to_dpi(2, dpi).clamp(2, 3) as f32;
let time_ring_radius =
(ring_radius - ring_stroke_w - scale_to_dpi(3, dpi) as f32).max(time_ring_stroke_w);
(ring_radius - ring_stroke_w - scale_to_dpi(4, dpi) as f32).max(time_ring_stroke_w);
let big_font_px = (head_diameter * 26 / 100).max(scale_to_dpi(11, dpi));
let big_font_px = (head_diameter * 24 / 100).max(scale_to_dpi(11, dpi));
let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi));
let main_font_px = small_font_px;
@@ -1096,15 +1099,20 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
};
let tail_left = head_diameter;
let tail_right = width_px - scale_to_dpi(12, dpi);
let tail_right = width_px - scale_to_dpi(14, dpi);
let pad = scale_to_dpi(6, dpi);
// Breathing room between bar end and the right-aligned text. 6 logical
// had the bar visually colliding with "100%" / countdown glyphs.
let bar_text_gap = scale_to_dpi(8, dpi);
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px);
let pct_reserve_w = measure_text_w(mem_dc, "100%", small_font_px) + scale_to_dpi(2, dpi);
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);
// Usage bar carries the primary signal; make it ~2.5x the mass of the
// time bar so the two lanes don't read as two competing quotas.
let usage_bar_h = (height_px * 10 / 100).clamp(scale_to_dpi(6, dpi), scale_to_dpi(12, dpi));
let time_bar_h = (height_px * 4 / 100).clamp(scale_to_dpi(3, dpi), scale_to_dpi(6, dpi));
let lane_gap = scale_to_dpi(6, 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;
@@ -1116,14 +1124,14 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
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 {
let text_w = if content_w >= desired_text_w + bar_text_gap + bar_min {
desired_text_w
} else {
(content_w - pad - bar_min).max(0)
(content_w - bar_text_gap - 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);
let bar_right = (text_left - bar_text_gap).max(bar_left + bar_min);
BubbleLayout {
canvas_w: width_px,
@@ -1181,19 +1189,21 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
Color::from_hex("#F3F3F3")
};
let track = if inputs.is_dark {
Color::from_hex("#3A3A3A")
Color::from_hex("#2C2C2C")
} else {
Color::from_hex("#D6D6D6")
Color::from_hex("#E2E2E2")
};
// Inner-ring / time-bar neutral track. Lifted off the background to
// clear WCAG 1.4.11 3:1 on dark themes (#303030 on #1F1F1F was ~1.13:1).
let time_track = if inputs.is_dark {
Color::from_hex("#303030")
Color::from_hex("#404040")
} else {
Color::from_hex("#E0E0E0")
};
let time_fill = if inputs.is_dark {
Color::from_hex("#9A9A9A")
Color::from_hex("#B0B0B0")
} else {
Color::from_hex("#777777")
Color::from_hex("#666666")
};
// ---- Stadium background ----
@@ -1306,7 +1316,12 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
let t = pulse_triangle(inputs.pulse_phase);
color = brighten(color, t);
}
let clipped_w = fill_w.min(bar_w);
// Floor at one cap-diameter so a 1% reading still renders
// as a recognizable dot rather than a sub-pixel sliver.
let mut clipped_w = fill_w.min(bar_w);
if clipped_w < bar_h {
clipped_w = bar_h.min(bar_w);
}
paint_pill(&mut pixmap, bar_x, bar_y, clipped_w, bar_h, cap, color);
}
}
@@ -1592,15 +1607,19 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
Color::from_hex("#1F1F1F")
};
let muted_color = if inputs.is_dark {
Color::from_hex("#888888")
Color::from_hex("#A8A8A8")
} else {
Color::from_hex("#6E6E6E")
Color::from_hex("#5E5E5E")
};
let font_name = wide_str("Segoe UI");
unsafe {
let big_font = create_font(layout.big_font_px, &font_name, FW_SEMIBOLD.0 as i32);
let small_font = create_font(layout.small_font_px, &font_name, FW_NORMAL.0 as i32);
// Big head percent uses FW_BOLD to anchor the eye against the ring.
// small_font is semibold because it carries both the "5H" window tag
// and the tail weekly-percent — both want a touch more weight than
// the countdown text rendered with main_font.
let big_font = create_font(layout.big_font_px, &font_name, FW_BOLD.0 as i32);
let small_font = create_font(layout.small_font_px, &font_name, FW_SEMIBOLD.0 as i32);
let main_font = create_font(layout.main_font_px, &font_name, FW_NORMAL.0 as i32);
SetBkMode(hdc, TRANSPARENT);
@@ -1615,13 +1634,13 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
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"
"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"
"5H"
};
draw_text_in_rect(hdc, &layout.head_label_rect, head_label_text, DT_CENTER);
@@ -1655,8 +1674,10 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
}
// Tail: weekly countdown aligned with its true remaining-time bar.
// Muted color — the percent above is the headline; the countdown is
// supporting context and should not compete for visual weight.
SelectObject(hdc, main_font);
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
if !inputs.weekly_text.is_empty() {
draw_tail_text_in_rect(hdc, &layout.tail_time_text_rect, &inputs.weekly_text, DT_RIGHT);
}