mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-06 10:12:43 +00:00
fix(bubble): move percent out of bar, next to countdown
Two issues reported on the inline-percent design:
1. "Same color as bar" — for Codex at safe levels the fill is white
(theme-adapted from the accent stripe colour) and the
luminance-based contrast switch wasn't landing reliably across the
fill/track boundary. White percent text disappeared into white fill.
2. "Not centered" — the percent anchored to the fill's trailing edge,
so its horizontal position changed with the bar's percentage. Read
more like a label on the fill than a centered numeric readout.
Fix: move the percent out of the bar entirely into its own column
between the bar and the countdown. Layout flow per row is now:
[accent] [5h] [bar ▓▓▓░░░░] [44%] [3h]
This puts the two numeric readouts ("44%" and "3h") side-by-side for
quick scanning while keeping the bar purely visual. The percent text
now renders on the bubble background — predictable, high-contrast in
both modes — and the entire luminance-contrast / fill-vs-track branch
is gone (`draw_inline_percent` replaced with a much shorter
`draw_percent`).
The percent column width is sized off the live font via
`GetTextExtentPoint32W("100%", ...)` so it always reserves exactly
enough room.
Bar usable width at default 200×66 drops from ~120 px to ~96 px
(stable across Claude vs Codex since the bar no longer holds text).
This commit is contained in:
+34
-69
@@ -877,11 +877,12 @@ fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
|
||||
const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
|
||||
const LABEL_PAD_LOGICAL: i32 = 6;
|
||||
// Worst-case width-probe for the right-side countdown column. The bubble
|
||||
// renders countdown-only (percent moved inline), so this is just "N{suffix}"
|
||||
// for the longest reasonable duration. Bumped from "100% · 23h" which had
|
||||
// been sized for the old combined string and left a big empty gap.
|
||||
const COUNTDOWN_TEMPLATE: &str = "999d";
|
||||
// Percent now lives in its own column between the bar and the countdown so
|
||||
// the two numeric readouts ("44%" and "3h") sit next to each other for
|
||||
// quick scanning, and the percent never has to fight the bar's fill colour
|
||||
// for contrast.
|
||||
const PERCENT_TEMPLATE: &str = "100%";
|
||||
|
||||
struct BarLayout {
|
||||
/// Bubble width in pixels.
|
||||
@@ -890,11 +891,14 @@ struct BarLayout {
|
||||
canvas_h: i32,
|
||||
/// Corner radius of the rounded rectangle.
|
||||
corner_radius: i32,
|
||||
/// Accent stripe (left edge) in pixels — Claude orange or Codex green.
|
||||
/// Accent stripe (left edge) in pixels — Claude orange or Codex neutral.
|
||||
accent_right: i32,
|
||||
/// Label column ("5h" / "7d").
|
||||
/// Row label column ("5h" / "7d").
|
||||
label_left: i32,
|
||||
label_right: i32,
|
||||
/// Percent column ("44%"), rendered on the bubble background.
|
||||
percent_left: i32,
|
||||
percent_right: i32,
|
||||
/// Bar geometry.
|
||||
bar_left: i32,
|
||||
bar_right: i32,
|
||||
@@ -905,7 +909,7 @@ struct BarLayout {
|
||||
/// Vertical positions (top edge of each row's bar).
|
||||
row1_y: i32,
|
||||
row2_y: i32,
|
||||
/// Font size for the main text (countdown + inline percent).
|
||||
/// Font size for the main text (percent + countdown).
|
||||
font_px: i32,
|
||||
/// Font size for the muted row labels — a notch smaller than `font_px`.
|
||||
label_font_px: i32,
|
||||
@@ -928,17 +932,23 @@ fn compute_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BarLayout {
|
||||
let font_px = scale_to_dpi(bp.font, dpi).max(scale_to_dpi(11, dpi));
|
||||
let label_font_px = scale_to_dpi(bp.font - 2, dpi).max(scale_to_dpi(9, dpi));
|
||||
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, font_px);
|
||||
let percent_w = measure_text_w(mem_dc, PERCENT_TEMPLATE, font_px);
|
||||
let label_w = measure_text_w(mem_dc, "5h", label_font_px)
|
||||
.max(measure_text_w(mem_dc, "7d", label_font_px));
|
||||
|
||||
// Layout (left → right):
|
||||
// [accent] [pad] [label] [pad] [bar] [pad] [percent] [pad] [countdown] [pad_x]
|
||||
let accent_left = 0;
|
||||
let accent_right = accent_left + accent_w;
|
||||
let label_left = accent_right + label_pad;
|
||||
let label_right = label_left + label_w;
|
||||
let bar_left = label_right + label_pad;
|
||||
|
||||
let right_text_right = width_px - pad_x;
|
||||
let right_text_left = (right_text_right - countdown_w).max(bar_left + scale_to_dpi(20, dpi));
|
||||
let bar_right = (right_text_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
||||
let percent_right = (right_text_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
||||
let percent_left = (percent_right - percent_w).max(bar_left + scale_to_dpi(20, dpi));
|
||||
let bar_right = (percent_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
||||
|
||||
let row1_y = pad_y;
|
||||
let row2_y = pad_y + bar_h + row_gap;
|
||||
@@ -950,6 +960,8 @@ fn compute_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BarLayout {
|
||||
accent_right,
|
||||
label_left,
|
||||
label_right,
|
||||
percent_left,
|
||||
percent_right,
|
||||
bar_left,
|
||||
bar_right,
|
||||
bar_h,
|
||||
@@ -1250,8 +1262,10 @@ fn blend(a: Color, b: Color, t: f64) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
/// One pass over the GDI text: row labels (muted) + inline percent (inside
|
||||
/// the bar) + countdown (right column).
|
||||
/// One pass over the GDI text: row labels (muted) + percent + countdown.
|
||||
/// The percent column lives between the bar and the countdown so the
|
||||
/// numeric readouts cluster together for quick scanning, and the percent
|
||||
/// text always sits on the bubble background — never on the bar fill.
|
||||
fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
|
||||
let text_color = if inputs.is_dark {
|
||||
Color::from_hex("#EAEAEA")
|
||||
@@ -1277,11 +1291,11 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
|
||||
draw_label(hdc, layout, layout.row1_y, "5h");
|
||||
draw_label(hdc, layout, layout.row2_y, "7d");
|
||||
|
||||
// Inline percent: drawn over the bar, contrast picked from the pixel
|
||||
// under the text (fill if covered, track otherwise).
|
||||
// Percent in its own column (between bar and countdown).
|
||||
SelectObject(hdc, bold_font);
|
||||
draw_inline_percent(hdc, layout, layout.row1_y, inputs.session_pct, inputs.model, inputs.is_dark);
|
||||
draw_inline_percent(hdc, layout, layout.row2_y, inputs.weekly_pct, inputs.model, inputs.is_dark);
|
||||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||||
draw_percent(hdc, layout, layout.row1_y, inputs.session_pct);
|
||||
draw_percent(hdc, layout, layout.row2_y, inputs.weekly_pct);
|
||||
|
||||
// Countdown on the right.
|
||||
SelectObject(hdc, main_font);
|
||||
@@ -1335,74 +1349,25 @@ fn draw_label(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_inline_percent(
|
||||
hdc: HDC,
|
||||
layout: &BarLayout,
|
||||
row_top: i32,
|
||||
pct: Option<f64>,
|
||||
model: TrayIconKind,
|
||||
is_dark: bool,
|
||||
) {
|
||||
fn draw_percent(hdc: HDC, layout: &BarLayout, row_top: i32, pct: Option<f64>) {
|
||||
let Some(p) = pct else {
|
||||
return;
|
||||
};
|
||||
let text = format!("{:.0}%", p);
|
||||
|
||||
// Measure the percent against the currently-selected font so we can
|
||||
// anchor it to the fill's trailing edge rather than the bar's far right.
|
||||
let mut wide: Vec<u16> = text.encode_utf16().collect();
|
||||
let mut sz = windows::Win32::Foundation::SIZE::default();
|
||||
unsafe {
|
||||
let _ = GetTextExtentPoint32W(hdc, &mut wide, &mut sz);
|
||||
}
|
||||
let text_w = sz.cx;
|
||||
|
||||
let bar_w = layout.bar_right - layout.bar_left;
|
||||
let fill_w = ((p.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
|
||||
let inset = (layout.bar_h / 4).max(2);
|
||||
let fill_color = bar_fill_color(model, is_dark, p);
|
||||
let track_color = if is_dark {
|
||||
Color::from_hex("#3A3A3A")
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
|
||||
// Two anchoring modes:
|
||||
// - Fill is wide enough to hold the percent → right-align the text
|
||||
// *inside* the fill at its trailing edge. The text sits on the fill.
|
||||
// - Fill is too narrow → left-align the text just to the right of the
|
||||
// fill, on the track. The text follows the fill's edge.
|
||||
// Either way the percent is tethered to where the bar reaches.
|
||||
let (text_left, underlying) = if fill_w >= text_w + inset * 2 {
|
||||
let right = layout.bar_left + fill_w - inset;
|
||||
((right - text_w).max(layout.bar_left + inset), fill_color)
|
||||
} else {
|
||||
let left = layout.bar_left + fill_w + inset;
|
||||
let clamped = left.min(layout.bar_right - text_w - inset).max(layout.bar_left + inset);
|
||||
(clamped, track_color)
|
||||
};
|
||||
|
||||
let fg = if use_dark_text_over(underlying) {
|
||||
Color::from_hex("#101010")
|
||||
} else {
|
||||
Color::from_hex("#F5F5F5")
|
||||
};
|
||||
|
||||
let mut text_buf = wide_str(&text);
|
||||
let len_no_nul = text_buf.len().saturating_sub(1);
|
||||
let mut rect = RECT {
|
||||
left: text_left,
|
||||
top: row_top - 2,
|
||||
right: (text_left + text_w).min(layout.bar_right),
|
||||
bottom: row_top + layout.bar_h + 2,
|
||||
left: layout.percent_left,
|
||||
top: row_top,
|
||||
right: layout.percent_right,
|
||||
bottom: row_top + layout.bar_h,
|
||||
};
|
||||
unsafe {
|
||||
SetTextColor(hdc, COLORREF(fg.into_colorref()));
|
||||
let _ = DrawTextW(
|
||||
hdc,
|
||||
&mut text_buf[..len_no_nul],
|
||||
&mut rect,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||||
DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user