Compare commits

...
4 Commits
Author SHA1 Message Date
tiennm99 391ad0cba2 chore: bump version to 0.1.15 2026-05-23 12:54:46 +07:00
tiennm99 77325b1e00 fix(bubble): restore alpha after GDI text so glyphs aren't transparent
User report on v0.1.14: text appears semi-transparent, desktop wallpaper
bleeds through glyph pixels.

Root cause: GDI's DrawTextW writes only RGB into 32bpp BI_RGB DIBs — the
"reserved" alpha byte (byte 3) is not preserved per the BITMAPINFOHEADER
contract. When UpdateLayeredWindow later composites with AC_SRC_ALPHA, it
reads alpha=0 at every glyph pixel and shows them as fully transparent.

The pre-v0.1.13 pipeline worked around this with an apply_alpha_mask
post-pass that OR'd 0xFF000000 into every pixel inside the rounded rect.
The stadium-shape rewrite (526786b) removed it on the false assumption
that tiny-skia's per-pixel alpha would "stick" through subsequent GDI
writes — but GDI runs *after* tiny-skia in the pipeline, so any pixel
GDI text writes to loses the alpha that tiny-skia set.

Fix: re-stamp the alpha channel from the original Pixmap after the GDI
text overlay. This restores tiny-skia's exact alpha values (255 in the
stadium interior, partial on the AA curved perimeter, 0 outside),
including the AA fade at the stadium's rounded ends.

Implementation:
- new helper `restore_alpha_from_pixmap(pixmap, dst)` next to the
  existing `copy_pixmap_to_dib`
- hoist `pixmap` out of the if-let arm in render() so it survives until
  after `paint_bubble_text`
- call `restore_alpha_from_pixmap` post-text

Two parallel reviewers (debugger + code-reviewer) converged on the same
diagnosis; the debugger preferred this approach for its simplicity and
because it's robust to any GDI behavior (whether alpha is zeroed,
untouched, or scribbled on, we overwrite with the known-good value).

Build clean.
2026-05-23 12:54:13 +07:00
tiennm99 c3d01f36d2 chore: bump version to 0.1.14 2026-05-23 12:30:52 +07:00
tiennm99 7bbf80e5f7 fix(bubble): tune head proportions — smaller percent glyph, more breathing room
User feedback on v0.1.13: design works, but the 5h percent glyph in the
head crowds the ring at small bubble sizes (the "100%" string was wider
than the ring's inner clear at MIN_BUBBLE_SIZE).

Two parallel UI/UX reviewers converged on:

- big_font_px ratio:   head_diameter × 26/100 (was 35/100), floor 11
- small_font_px ratio: big × 55/100         (was 45/100), floor 9
- head_pad:            4 logical px         (was 6) — recovers 4px
                       of inner clear at small sizes
- ring_stroke_w:       clamped to [2, 4]    (was floor 2 only)
- label/glyph gap:     big × 15/100, floor 2 (was implicit 0)
- tail_bar_h:          5 logical px         (was 6) — restores
                       proportion against the 3-px head ring stroke

Worked example at MIN_BUBBLE_SIZE=140 (head_diameter=47):
  before: "100%" glyph ≈ 32px wide vs 28px ring inner — overflow
  after:  "100%" glyph ≈ 23px wide vs 32px ring inner — comfortable

Worked example at MAX_BUBBLE_SIZE=360 (head_diameter=138):
  glyph ≈ 70px wide in 124px inner clear (~57%) — confident not crowding

Deliberately not applying:
- drop "7d" label (one reviewer wanted it): rejected — symmetry with
  "5h" matters for self-explanation at a glance, and the ~14px cost is
  acceptable
- head_diameter bump to canvas_h × 1.08: rejected — only useful coupled
  with the label drop

Build clean.
2026-05-23 12:30:18 +07:00
3 changed files with 38 additions and 11 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"dirs",
"embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.13"
version = "0.1.15"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
+36 -9
View File
@@ -979,8 +979,8 @@ 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(6, dpi);
let ring_stroke_w = scale_to_dpi(3, dpi).max(2) as f32;
let head_pad = scale_to_dpi(4, 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;
// Ring centerline: midway between outer and inner edge, then keep stroke
@@ -988,13 +988,14 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32);
let ring_radius = ring_outer - ring_stroke_w / 2.0;
let big_font_px = (head_diameter * 35 / 100).max(scale_to_dpi(12, dpi));
let small_font_px = ((big_font_px * 45) / 100).max(scale_to_dpi(8, dpi));
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));
let main_font_px = small_font_px;
let head_label_h = small_font_px + scale_to_dpi(2, dpi);
let head_pct_h = big_font_px + scale_to_dpi(2, dpi);
let head_total_h = head_label_h + head_pct_h;
let label_pct_gap = (big_font_px * 15 / 100).max(scale_to_dpi(2, dpi));
let head_total_h = head_label_h + label_pct_gap + head_pct_h;
let head_text_top = (height_px - head_total_h) / 2;
let head_label_rect = RECT {
left: scale_to_dpi(4, dpi),
@@ -1004,7 +1005,7 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
};
let head_pct_rect = RECT {
left: scale_to_dpi(4, dpi),
top: head_text_top + head_label_h,
top: head_text_top + head_label_h + label_pct_gap,
right: head_diameter - scale_to_dpi(4, dpi),
bottom: head_text_top + head_total_h,
};
@@ -1023,7 +1024,7 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
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(6, dpi);
let tail_bar_h = scale_to_dpi(5, dpi);
let tail_bar_top = (height_px - tail_bar_h) / 2;
BubbleLayout {
@@ -1225,6 +1226,19 @@ fn copy_pixmap_to_dib(pixmap: &Pixmap, dst: &mut [u32]) {
}
}
/// Re-stamp the alpha byte of every DIB pixel from the source `Pixmap`. Used
/// after GDI text rendering, which writes RGB but leaves the BI_RGB DIB's
/// "reserved" alpha byte at zero — making glyph pixels appear transparent
/// when `UpdateLayeredWindow` composites with `AC_SRC_ALPHA`.
fn restore_alpha_from_pixmap(pixmap: &Pixmap, dst: &mut [u32]) {
let src = pixmap.data();
let pixel_count = (pixmap.width() * pixmap.height()) as usize;
for i in 0..pixel_count {
let a = src[i * 4 + 3] as u32;
dst[i] = (dst[i] & 0x00FF_FFFF) | (a << 24);
}
}
fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 {
use windows::Win32::Foundation::SIZE;
let font_name = wide_str("Segoe UI");
@@ -1325,12 +1339,25 @@ fn render(hwnd: HWND) {
// Paint shape via tiny-skia (AA), then copy into the DIB. GDI text
// overlays on top of the resulting bitmap.
if let Some(pixmap) = paint_bubble_pixmap(&layout, &inputs) {
copy_pixmap_to_dib(&pixmap, pixels);
let pixmap_opt = paint_bubble_pixmap(&layout, &inputs);
if let Some(ref pixmap) = pixmap_opt {
copy_pixmap_to_dib(pixmap, pixels);
} else {
pixels.fill(0);
}
paint_bubble_text(mem_dc, &layout, &inputs);
// GDI text writes RGB into the 32bpp BI_RGB DIB but does not preserve
// the alpha byte (per the BITMAPINFOHEADER contract: byte 3 is
// "reserved/0" for BI_RGB). UpdateLayeredWindow with AC_SRC_ALPHA then
// reads those zeroed alpha bytes and paints the glyph pixels as fully
// transparent — desktop bleeds through. Fix: re-stamp the alpha
// channel from the tiny-skia Pixmap we still have in scope. This
// preserves the AA alpha on the stadium's curved perimeter and forces
// glyph pixels back to the opacity tiny-skia computed for that
// location (255 in the interior, 0 outside).
if let Some(ref pixmap) = pixmap_opt {
restore_alpha_from_pixmap(pixmap, pixels);
}
let mut wr = RECT::default();
let _ = GetWindowRect(hwnd, &mut wr);