feat: redesign bubble as two-bar rounded rect, finish port wiring

Bubble shape changes from circle to rounded rectangle showing two stacked
horizontal bars — top: session (5h), bottom: weekly (7d) — each followed
by a right-aligned "X% · Yh Zm" string (percent + countdown).

Bubble surface:
- BubbleConfig/BubbleState carry session+weekly percents and texts (mirrors
  PanelData); update_percentage renamed to update_data
- Aspect ratio fixed at 3:1; size_logical is interpreted as width with
  height derived. Clamp is 120..360 (was 32..128 square)
- Hit-testing uses a rounded-rect predicate (point_in_rounded_rect) shared
  with the alpha mask so paint and click area can't drift
- New rgb_to_dib helper for direct DIB writes — BI_RGB 32bpp stores B,G,R,X
  in memory which is the opposite of COLORREF. The previous code wrote
  COLORREF-packed u32 straight into DIB pixels; invisible while every color
  was gray, but the new orange/red bar fills would have rendered blue
- bar_h capped at h/4 (range 6..18) so the text font derived from it stays
  small enough that "100% · 23h" fits in right_text_w (= 6×bar_h, min 56);
  the first iteration had a 19-px font in a 60-px column and ellipsized
  away the countdown
- Initial session_text/weekly_text seeded with "…" so the bubble has
  visible feedback during the first poll instead of two empty grey tracks

Compile + cleanup needed to make the port build at all:
- Color::from_hex added back as an infallible wrapper around parse_hex
  (15 call sites in bubble.rs/panel.rs assumed the old infallible API)
- Color::to_colorref → into_colorref at 5 call sites
- GetModuleFileNameW added to the LibraryLoader import in bubble.rs
- usage::Error gains Creds(#[from] creds::Error) so `?` works in the
  Anthropic and ChatGPT providers
- FlattenBoxed::flatten renamed to flatten_box — the std Option::flatten
  was shadowing it and yielding Option<Box<T>> instead of Option<T>
- PanelState marked unsafe Send (HWND has *mut c_void; state is only
  touched from the UI thread, Mutex is for OnceLock satisfaction)
- Crate-level #![allow(dead_code)] for in-progress port API surface
  (creds, usage, update, os::dpi); unused pub-use re-exports removed

App wiring:
- propagate_to_ui now feeds both windows + their formatted texts into
  update_data (was a single percent)
This commit is contained in:
tiennm99 committed 2026-05-16 11:11:34 +07:00
1 parent aa6217d2cf
commit ee4e1c9b26
10 files changed
+1839 -149

No files matched your search

Generated
+1514
View File
File diff suppressed because it is too large. Load diff
+20 -6
View File
@@ -249,11 +249,18 @@ fn create_initial_bubbles() {
} }
fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) { fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
// "…" matches the in-flight/transient-error placeholder used by
// `apply_results`, so the bubble has visible feedback during the first
// poll rather than rendering with two empty grey tracks.
let placeholder = "…".to_string();
let hwnd = bubble::create(bubble::BubbleConfig { let hwnd = bubble::create(bubble::BubbleConfig {
model: kind, model: kind,
size_logical: settings.bubble_size_logical, size_logical: settings.bubble_size_logical,
position: settings.bubble_positions.get(kind), position: settings.bubble_positions.get(kind),
percent: None, session_pct: None,
session_text: placeholder.clone(),
weekly_pct: None,
weekly_text: placeholder,
is_dark, is_dark,
}); });
if hwnd != HWND::default() { if hwnd != HWND::default() {
@@ -492,11 +499,18 @@ fn propagate_to_ui() {
for (kind, hwnd) in snap.bubbles.iter() { for (kind, hwnd) in snap.bubbles.iter() {
let id = kind_to_provider(*kind); let id = kind_to_provider(*kind);
let pct = snap let entry = snap.snapshots.get(&id);
.snapshots let session_pct = entry.map(|s| s.windows.primary.utilization);
.get(&id) let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
.map(|s| s.windows.primary.utilization); let session_text = entry.map(|s| s.primary_text.clone()).unwrap_or_default();
bubble::update_percentage(hwnd.to_hwnd(), pct); let weekly_text = entry.map(|s| s.secondary_text.clone()).unwrap_or_default();
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
session_text,
weekly_pct,
weekly_text,
);
} }
refresh_tray_icons_with(&snap); refresh_tray_icons_with(&snap);
+271 -128
View File
@@ -1,10 +1,15 @@
// Floating circular bubble window. // Floating rounded-rectangle bubble window.
// //
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE. // Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
// Shape is achieved via per-pixel alpha (alpha=0 outside the circle) and confirmed // Shape is achieved via per-pixel alpha (alpha=0 outside the rounded rect) and
// via WM_NCHITTEST returning HTCAPTION inside the circle, HTTRANSPARENT outside. // confirmed via WM_NCHITTEST returning HTCAPTION inside the rect, HTTRANSPARENT
// The OS handles drag automatically because HTCAPTION inside the circle puts the // outside. The OS handles drag automatically because HTCAPTION inside the
// click into the system move loop. // rect puts the click into the system move loop.
//
// Layout: two horizontal bars stacked vertically — top = session (5h), bottom =
// weekly (7d) — each followed by right-aligned "X% · Yh Zm" text. The aspect
// ratio is fixed at BUBBLE_ASPECT (3:1) so `size_logical` is interpreted as
// width and the height is derived.
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::c_void; use std::ffi::c_void;
@@ -13,7 +18,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
use windows::core::PCWSTR; use windows::core::PCWSTR;
use windows::Win32::Foundation::*; use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Gdi::*; use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
use windows::Win32::UI::HiDpi::*; use windows::Win32::UI::HiDpi::*;
use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::Shell::ExtractIconExW;
use windows::Win32::UI::WindowsAndMessaging::*; use windows::Win32::UI::WindowsAndMessaging::*;
@@ -26,10 +31,12 @@ type TrayIconKind = ProviderId;
// ---------- Public types & API ---------- // ---------- Public types & API ----------
pub const MIN_BUBBLE_SIZE: i32 = 32; // Width clamps in logical pixels (height = width / BUBBLE_ASPECT).
pub const MAX_BUBBLE_SIZE: i32 = 128; pub const MIN_BUBBLE_SIZE: i32 = 120;
pub const DEFAULT_BUBBLE_SIZE: i32 = 56; pub const MAX_BUBBLE_SIZE: i32 = 360;
const RESIZE_STEP: i32 = 8; pub const DEFAULT_BUBBLE_SIZE: i32 = 180;
const RESIZE_STEP: i32 = 20;
const BUBBLE_ASPECT: i32 = 3; // width : height = 3 : 1
const SNAP_ZONE_LOGICAL: i32 = 12; const SNAP_ZONE_LOGICAL: i32 = 12;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble"; const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500; const FULLSCREEN_POLL_MS: u32 = 1500;
@@ -38,10 +45,17 @@ pub struct BubbleConfig {
pub model: TrayIconKind, pub model: TrayIconKind,
pub size_logical: i32, pub size_logical: i32,
pub position: Option<(i32, i32)>, pub position: Option<(i32, i32)>,
pub percent: Option<f64>, pub session_pct: Option<f64>,
pub session_text: String,
pub weekly_pct: Option<f64>,
pub weekly_text: String,
pub is_dark: bool, pub is_dark: bool,
} }
fn bubble_height_logical(width_logical: i32) -> i32 {
(width_logical / BUBBLE_ASPECT).max(20)
}
/// Register the bubble window class. Idempotent; safe to call before the first /// Register the bubble window class. Idempotent; safe to call before the first
/// `create()` from the UI thread. /// `create()` from the UI thread.
pub fn register_class() { pub fn register_class() {
@@ -77,11 +91,11 @@ pub fn create(config: BubbleConfig) -> HWND {
let title_w = wide_str("Claude Code Usage Bubble"); let title_w = wide_str("Claude Code Usage Bubble");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let dpi = primary_dpi(); let dpi = primary_dpi();
let size_px = scale_to_dpi(initial_size_logical, dpi); let width_px = scale_to_dpi(initial_size_logical, dpi);
let (x, y) = let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi);
config let (x, y) = config
.position .position
.unwrap_or_else(|| default_position(size_px, config.model)); .unwrap_or_else(|| default_position(width_px, height_px, config.model));
CreateWindowExW( CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
PCWSTR::from_raw(class_w.as_ptr()), PCWSTR::from_raw(class_w.as_ptr()),
@@ -89,8 +103,8 @@ pub fn create(config: BubbleConfig) -> HWND {
WS_POPUP, WS_POPUP,
x, x,
y, y,
size_px, width_px,
size_px, height_px,
HWND::default(), HWND::default(),
HMENU::default(), HMENU::default(),
hinstance, hinstance,
@@ -143,7 +157,10 @@ pub fn create(config: BubbleConfig) -> HWND {
model: config.model, model: config.model,
size_logical: initial_size_logical, size_logical: initial_size_logical,
dpi, dpi,
percent: config.percent, session_pct: config.session_pct,
session_text: config.session_text,
weekly_pct: config.weekly_pct,
weekly_text: config.weekly_text,
is_dark: config.is_dark, is_dark: config.is_dark,
drag_start_pos: None, drag_start_pos: None,
hidden_by_fullscreen: false, hidden_by_fullscreen: false,
@@ -168,13 +185,22 @@ pub fn destroy(hwnd: HWND) {
} }
} }
pub fn update_percentage(hwnd: HWND, percent: Option<f64>) { pub fn update_data(
hwnd: HWND,
session_pct: Option<f64>,
session_text: String,
weekly_pct: Option<f64>,
weekly_text: String,
) {
{ {
let mut bubbles = lock_bubbles(); let mut bubbles = lock_bubbles();
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else { let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
return; return;
}; };
b.percent = percent; b.session_pct = session_pct;
b.session_text = session_text;
b.weekly_pct = weekly_pct;
b.weekly_text = weekly_text;
} }
render(hwnd); render(hwnd);
} }
@@ -232,7 +258,10 @@ struct BubbleState {
model: TrayIconKind, model: TrayIconKind,
size_logical: i32, size_logical: i32,
dpi: u32, dpi: u32,
percent: Option<f64>, session_pct: Option<f64>,
session_text: String,
weekly_pct: Option<f64>,
weekly_text: String,
is_dark: bool, is_dark: bool,
drag_start_pos: Option<(i32, i32)>, drag_start_pos: Option<(i32, i32)>,
hidden_by_fullscreen: bool, hidden_by_fullscreen: bool,
@@ -363,18 +392,41 @@ fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
return LRESULT(HTNOWHERE as isize); return LRESULT(HTNOWHERE as isize);
} }
} }
let cx = (r.left + r.right) / 2; let w = r.right - r.left;
let cy = (r.top + r.bottom) / 2; let h = r.bottom - r.top;
let radius = ((r.right - r.left) / 2).max(1); let radius = corner_radius_px(h);
let dx = pt.x - cx; // Local coordinates relative to top-left of the bubble.
let dy = pt.y - cy; let lx = pt.x - r.left;
if dx * dx + dy * dy <= radius * radius { let ly = pt.y - r.top;
if point_in_rounded_rect(lx, ly, w, h, radius) {
LRESULT(HTCAPTION as isize) LRESULT(HTCAPTION as isize)
} else { } else {
LRESULT(HTTRANSPARENT as isize) LRESULT(HTTRANSPARENT as isize)
} }
} }
fn corner_radius_px(height_px: i32) -> i32 {
(height_px / 3).max(4)
}
fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
if x < 0 || y < 0 || x >= w || y >= h {
return false;
}
// The straight horizontal and vertical strips are always inside; only the
// four corner squares need the circular falloff check.
let in_x_strip = x >= r && x < w - r;
let in_y_strip = y >= r && y < h - r;
if in_x_strip || in_y_strip {
return true;
}
let cx = if x < r { r } else { w - 1 - r };
let cy = if y < r { r } else { h - 1 - r };
let dx = x - cx;
let dy = y - cy;
dx * dx + dy * dy <= r * r
}
fn lparam_to_point(lparam: LPARAM) -> POINT { fn lparam_to_point(lparam: LPARAM) -> POINT {
let lo = (lparam.0 & 0xFFFF) as i16 as i32; let lo = (lparam.0 & 0xFFFF) as i16 as i32;
let hi = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32; let hi = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32;
@@ -396,22 +448,23 @@ fn resize_step(hwnd: HWND, delta: i32) {
b.size_logical = new_logical; b.size_logical = new_logical;
(new_logical, b.dpi) (new_logical, b.dpi)
}; };
let size_px = scale_to_dpi(new_logical, dpi); let width_px = scale_to_dpi(new_logical, dpi);
let height_px = scale_to_dpi(bubble_height_logical(new_logical), dpi);
let mut r = RECT::default(); let mut r = RECT::default();
unsafe { unsafe {
let _ = GetWindowRect(hwnd, &mut r); let _ = GetWindowRect(hwnd, &mut r);
// Resize centered on existing center. // Resize centered on existing center.
let cx = (r.left + r.right) / 2; let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2; let cy = (r.top + r.bottom) / 2;
let new_x = cx - size_px / 2; let new_x = cx - width_px / 2;
let new_y = cy - size_px / 2; let new_y = cy - height_px / 2;
let _ = SetWindowPos( let _ = SetWindowPos(
hwnd, hwnd,
HWND::default(), HWND::default(),
new_x, new_x,
new_y, new_y,
size_px, width_px,
size_px, height_px,
SWP_NOZORDER | SWP_NOACTIVATE, SWP_NOZORDER | SWP_NOACTIVATE,
); );
} }
@@ -541,15 +594,72 @@ fn check_fullscreen(bubble_hwnd: HWND) {
// ---------- Painting ---------- // ---------- Painting ----------
struct BarLayout {
bar_left: i32,
bar_right: i32,
bar_h: i32,
right_text_left: i32,
right_text_w: i32,
row1_y: i32,
row2_y: i32,
}
fn compute_layout(width_px: i32, height_px: i32) -> BarLayout {
let pad_x = (height_px / 6).max(3);
// Bar height is capped — the previous formula filled all the vertical space
// and produced a too-tall font that overflowed the right-text column. Cap
// at ~1/4 of the bubble height so the rows have visible padding around them.
let bar_h = (height_px / 4).clamp(6, 18);
let row_gap = (bar_h / 2).max(3);
let pad_y = ((height_px - bar_h * 2 - row_gap) / 2).max(2);
// Right-side text needs ~6× the bar height to fit "100% · 23h" comfortably
// at the font size derived below (bar_h * 0.75). Floor in px for the
// smallest legible width.
let right_text_w = (bar_h * 6).max(56);
let bar_left = pad_x;
let bar_right = (width_px - pad_x - right_text_w - bar_h / 2).max(bar_left + 8);
let right_text_left = bar_right + (bar_h / 2).max(2);
let row1_y = pad_y;
let row2_y = pad_y + bar_h + row_gap;
BarLayout {
bar_left,
bar_right,
bar_h,
right_text_left,
right_text_w,
row1_y,
row2_y,
}
}
/// Pack an `Rgb` for direct write into a 32-bpp `BI_RGB` DIB. The DIB stores
/// bytes B,G,R,X in memory, so a little-endian u32 read is `(b) | (g<<8) | (r<<16)`.
/// Note this is the OPPOSITE byte order from a GDI `COLORREF` (which is
/// `(r) | (g<<8) | (b<<16)`) — don't confuse the two.
fn rgb_to_dib(c: Color) -> u32 {
(c.b as u32) | ((c.g as u32) << 8) | ((c.r as u32) << 16)
}
fn render(hwnd: HWND) { fn render(hwnd: HWND) {
let (size_logical, dpi, percent, is_dark) = { let (size_logical, dpi, session_pct, session_text, weekly_pct, weekly_text, is_dark) = {
let bubbles = lock_bubbles(); let bubbles = lock_bubbles();
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else { let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
return; return;
}; };
(b.size_logical, b.dpi, b.percent, b.is_dark) (
b.size_logical,
b.dpi,
b.session_pct,
b.session_text.clone(),
b.weekly_pct,
b.weekly_text.clone(),
b.is_dark,
)
}; };
let size_px = scale_to_dpi(size_logical, dpi); let width_px = scale_to_dpi(size_logical, dpi);
let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi);
let radius = corner_radius_px(height_px);
let layout = compute_layout(width_px, height_px);
unsafe { unsafe {
let screen_dc = GetDC(hwnd); let screen_dc = GetDC(hwnd);
@@ -557,8 +667,8 @@ fn render(hwnd: HWND) {
let bmi = BITMAPINFO { let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER { bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32, biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size_px, biWidth: width_px,
biHeight: -size_px, biHeight: -height_px,
biPlanes: 1, biPlanes: 1,
biBitCount: 32, biBitCount: 32,
biCompression: 0, biCompression: 0,
@@ -576,30 +686,25 @@ fn render(hwnd: HWND) {
} }
let old_bmp = SelectObject(mem_dc, dib); let old_bmp = SelectObject(mem_dc, dib);
let pixel_count = (size_px * size_px) as usize; let pixel_count = (width_px * height_px) as usize;
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
paint_background(pixels, size_px, is_dark); // Everything outside the rounded rect stays 0 (fully transparent).
paint_ring(pixels, size_px, percent.unwrap_or(0.0), is_dark); pixels.fill(0);
paint_text(mem_dc, size_px, percent, is_dark, dpi);
// Final alpha pass: set alpha=255 inside circle, 0 outside. paint_background(pixels, width_px, height_px, radius, is_dark);
let cx = (size_px - 1) as f64 / 2.0; paint_bars(pixels, width_px, &layout, session_pct, weekly_pct, is_dark);
let cy = cx; paint_bar_texts(
let radius = (size_px / 2) as f64 - 1.0; mem_dc,
let r_sq = radius * radius; &layout,
for y in 0..size_px { &session_text,
for x in 0..size_px { &weekly_text,
let dx = x as f64 - cx; is_dark,
let dy = y as f64 - cy; );
let idx = (y * size_px + x) as usize;
if dx * dx + dy * dy <= r_sq { // Final alpha pass: alpha=255 inside the rounded rect, 0 outside. This
pixels[idx] |= 0xFF000000; // also lifts GDI-drawn text (which leaves alpha=0) into the visible plane.
} else { apply_alpha_mask(pixels, width_px, height_px, radius);
pixels[idx] = 0;
}
}
}
let mut wr = RECT::default(); let mut wr = RECT::default();
let _ = GetWindowRect(hwnd, &mut wr); let _ = GetWindowRect(hwnd, &mut wr);
@@ -609,8 +714,8 @@ fn render(hwnd: HWND) {
}; };
let pt_src = POINT { x: 0, y: 0 }; let pt_src = POINT { x: 0, y: 0 };
let sz = SIZE { let sz = SIZE {
cx: size_px, cx: width_px,
cy: size_px, cy: height_px,
}; };
let blend = BLENDFUNCTION { let blend = BLENDFUNCTION {
BlendOp: 0, BlendOp: 0,
@@ -637,84 +742,96 @@ fn render(hwnd: HWND) {
} }
} }
fn paint_background(pixels: &mut [u32], size_px: i32, is_dark: bool) { fn paint_background(pixels: &mut [u32], w: i32, h: i32, radius: i32, is_dark: bool) {
let bg = if is_dark { let bg = if is_dark {
Color::from_hex("#1F1F1F") Color::from_hex("#1F1F1F")
} else { } else {
Color::from_hex("#F3F3F3") Color::from_hex("#F3F3F3")
}; };
let bg_bgr = bg.to_colorref(); let bg_packed = rgb_to_dib(bg);
let cx = (size_px - 1) as f64 / 2.0; for y in 0..h {
let cy = cx; for x in 0..w {
let radius = (size_px / 2) as f64 - 1.0; if point_in_rounded_rect(x, y, w, h, radius) {
let r_sq = radius * radius; pixels[(y * w + x) as usize] = bg_packed;
for y in 0..size_px {
for x in 0..size_px {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let idx = (y * size_px + x) as usize;
if dx * dx + dy * dy <= r_sq {
pixels[idx] = bg_bgr;
} else {
pixels[idx] = 0;
} }
} }
} }
} }
fn paint_ring(pixels: &mut [u32], size_px: i32, percent: f64, is_dark: bool) { fn paint_bars(
let ring = ring_color_for_percent(percent).to_colorref(); pixels: &mut [u32],
width_px: i32,
layout: &BarLayout,
session_pct: Option<f64>,
weekly_pct: Option<f64>,
is_dark: bool,
) {
let track = if is_dark { let track = if is_dark {
Color::from_hex("#3A3A3A").to_colorref() rgb_to_dib(Color::from_hex("#3A3A3A"))
} else { } else {
Color::from_hex("#D6D6D6").to_colorref() rgb_to_dib(Color::from_hex("#D6D6D6"))
}; };
let cx = (size_px - 1) as f64 / 2.0; paint_bar(pixels, width_px, layout, layout.row1_y, session_pct, track);
let cy = cx; paint_bar(pixels, width_px, layout, layout.row2_y, weekly_pct, track);
let outer = (size_px / 2) as f64 - 1.0; }
let thickness = ((size_px as f64) * 0.12).max(3.0);
let inner = outer - thickness; fn paint_bar(
let inner_sq = inner * inner; pixels: &mut [u32],
let outer_sq = outer * outer; width_px: i32,
let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 2.0 * std::f64::consts::PI; layout: &BarLayout,
for y in 0..size_px { top: i32,
for x in 0..size_px { pct: Option<f64>,
let dx = x as f64 - cx; track: u32,
let dy = y as f64 - cy; ) {
let d_sq = dx * dx + dy * dy; let bar_w = layout.bar_right - layout.bar_left;
if d_sq <= outer_sq && d_sq >= inner_sq { if bar_w <= 0 {
// Angle from 12 o'clock, clockwise. return;
let mut a = dx.atan2(-dy); }
if a < 0.0 { // Track first.
a += 2.0 * std::f64::consts::PI; for y in top..top + layout.bar_h {
} for x in layout.bar_left..layout.bar_right {
let idx = (y * size_px + x) as usize; pixels[(y * width_px + x) as usize] = track;
pixels[idx] = if a <= sweep { ring } else { track }; }
} }
let Some(p) = pct else {
return;
};
let fill_w = ((p.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
if fill_w <= 0 {
return;
}
let accent = rgb_to_dib(ring_color_for_percent(p));
let end_x = (layout.bar_left + fill_w).min(layout.bar_right);
for y in top..top + layout.bar_h {
for x in layout.bar_left..end_x {
pixels[(y * width_px + x) as usize] = accent;
} }
} }
} }
fn paint_text(hdc: HDC, size_px: i32, percent: Option<f64>, is_dark: bool, _dpi: u32) { fn paint_bar_texts(
let text = match percent { hdc: HDC,
Some(p) => format!("{:.0}%", p), layout: &BarLayout,
None => "—".to_string(), session_text: &str,
}; weekly_text: &str,
let mut text_w = wide_str(&text); is_dark: bool,
) {
let text_color = if is_dark { let text_color = if is_dark {
Color::from_hex("#F5F5F5") Color::from_hex("#EAEAEA")
} else { } else {
Color::from_hex("#1F1F1F") Color::from_hex("#1F1F1F")
}; };
let font_height = -(size_px / 4).max(8); // Match font height to ~75% of the bar height — leaves room above/below
// for descenders and keeps "100% · 23h" within `right_text_w`.
let font_size = (layout.bar_h as f64 * 0.75).round() as i32;
let font_name = wide_str("Segoe UI"); let font_name = wide_str("Segoe UI");
unsafe { unsafe {
let font = CreateFontW( let font = CreateFontW(
font_height, -font_size.max(8),
0, 0,
0, 0,
0, 0,
FW_SEMIBOLD.0 as i32, FW_NORMAL.0 as i32,
0, 0,
0, 0,
0, 0,
@@ -726,24 +843,50 @@ fn paint_text(hdc: HDC, size_px: i32, percent: Option<f64>, is_dark: bool, _dpi:
PCWSTR::from_raw(font_name.as_ptr()), PCWSTR::from_raw(font_name.as_ptr()),
); );
let old_font = SelectObject(hdc, font); let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(text_color.to_colorref())); SetTextColor(hdc, COLORREF(text_color.into_colorref()));
SetBkMode(hdc, TRANSPARENT); SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: 0, draw_right_text(hdc, layout, layout.row1_y, session_text);
top: 0, draw_right_text(hdc, layout, layout.row2_y, weekly_text);
right: size_px,
bottom: size_px, SelectObject(hdc, old_font);
}; let _ = DeleteObject(font);
// Trim the trailing NUL — DrawTextW reads slice length as count. }
let len_no_nul = text_w.len().saturating_sub(1); }
fn draw_right_text(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) {
if text.is_empty() {
return;
}
let mut text_w = wide_str(text);
let len_no_nul = text_w.len().saturating_sub(1);
// Vertically center within the bar row.
let mut rect = RECT {
left: layout.right_text_left,
top: row_top - 2,
right: layout.right_text_left + layout.right_text_w,
bottom: row_top + layout.bar_h + 2,
};
unsafe {
let _ = DrawTextW( let _ = DrawTextW(
hdc, hdc,
&mut text_w[..len_no_nul], &mut text_w[..len_no_nul],
&mut rect, &mut rect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP | DT_END_ELLIPSIS,
); );
SelectObject(hdc, old_font); }
let _ = DeleteObject(font); }
fn apply_alpha_mask(pixels: &mut [u32], w: i32, h: i32, radius: i32) {
for y in 0..h {
for x in 0..w {
let idx = (y * w + x) as usize;
if point_in_rounded_rect(x, y, w, h, radius) {
pixels[idx] |= 0xFF00_0000;
} else {
pixels[idx] = 0;
}
}
} }
} }
@@ -788,7 +931,7 @@ fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
((logical as i64) * (dpi as i64) / 96) as i32 ((logical as i64) * (dpi as i64) / 96) as i32
} }
fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) { fn default_position(width_px: i32, height_px: i32, model: TrayIconKind) -> (i32, i32) {
// Bottom-right of primary work area, with a 24-pixel gap from the edges. // Bottom-right of primary work area, with a 24-pixel gap from the edges.
// Stagger the Codex bubble above the Claude one if both are enabled. // Stagger the Codex bubble above the Claude one if both are enabled.
unsafe { unsafe {
@@ -810,10 +953,10 @@ fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) {
let gap = 24; let gap = 24;
let stagger = match model { let stagger = match model {
ProviderId::Claude => 0, ProviderId::Claude => 0,
ProviderId::ChatGpt => size_px + gap, ProviderId::ChatGpt => height_px + gap,
}; };
let x = wa.right - size_px - gap; let x = wa.right - width_px - gap;
let y = wa.bottom - size_px - gap - stagger; let y = wa.bottom - height_px - gap - stagger;
(x, y) (x, y)
} }
} }
+4
View File
@@ -1,4 +1,8 @@
#![windows_subsystem = "windows"] #![windows_subsystem = "windows"]
// Several modules (creds, usage, update, os::dpi, …) expose API that the
// app surface doesn't fully call yet — they're scaffolding for in-progress
// port phases. Allow at the crate root rather than scattering attributes.
#![allow(dead_code)]
// Original infrastructure. // Original infrastructure.
mod creds; mod creds;
+6
View File
@@ -16,6 +16,12 @@ impl Rgb {
Self { r, g, b } Self { r, g, b }
} }
/// Parse `#RRGGBB` or `RRGGBB`. Panics on malformed input — meant for
/// hardcoded design-system literals at compile-known call sites.
pub fn from_hex(hex: &str) -> Self {
Self::parse_hex(hex).unwrap_or_else(|| panic!("invalid hex color literal: {hex:?}"))
}
/// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input. /// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input.
pub fn parse_hex(hex: &str) -> Option<Self> { pub fn parse_hex(hex: &str) -> Option<Self> {
let s = hex.trim_start_matches('#'); let s = hex.trim_start_matches('#');
+9 -4
View File
@@ -41,6 +41,11 @@ struct PanelState {
data: PanelData, data: PanelData,
} }
// HWND wraps `*mut c_void` which is `!Send`, but the panel state is only ever
// accessed from the UI thread — the Mutex exists to satisfy the `OnceLock`
// static contract, not because of real cross-thread sharing.
unsafe impl Send for PanelState {}
fn state() -> &'static Mutex<Option<PanelState>> { fn state() -> &'static Mutex<Option<PanelState>> {
static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new(); static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None)) S.get_or_init(|| Mutex::new(None))
@@ -267,7 +272,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
let accent = bar_color_for(data.session_pct.max(data.weekly_pct), data.is_dark); let accent = bar_color_for(data.session_pct.max(data.weekly_pct), data.is_dark);
unsafe { unsafe {
let bg_brush = CreateSolidBrush(COLORREF(bg.to_colorref())); let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
FillRect(hdc, &rc, bg_brush); FillRect(hdc, &rc, bg_brush);
let _ = DeleteObject(bg_brush); let _ = DeleteObject(bg_brush);
@@ -362,7 +367,7 @@ fn draw_row(
); );
unsafe { unsafe {
let track_brush = CreateSolidBrush(COLORREF(track.to_colorref())); let track_brush = CreateSolidBrush(COLORREF(track.into_colorref()));
let bar_rect = RECT { let bar_rect = RECT {
left: bar_x, left: bar_x,
top: row_y, top: row_y,
@@ -374,7 +379,7 @@ fn draw_row(
let fill_w = ((pct.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32; let fill_w = ((pct.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
if fill_w > 0 { if fill_w > 0 {
let accent_brush = CreateSolidBrush(COLORREF(accent.to_colorref())); let accent_brush = CreateSolidBrush(COLORREF(accent.into_colorref()));
let fill_rect = RECT { let fill_rect = RECT {
left: bar_x, left: bar_x,
top: row_y, top: row_y,
@@ -439,7 +444,7 @@ fn draw_text(
PCWSTR::from_raw(font_name.as_ptr()), PCWSTR::from_raw(font_name.as_ptr()),
); );
let old_font = SelectObject(hdc, font); let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(color.to_colorref())); SetTextColor(hdc, COLORREF(color.into_colorref()));
SetBkMode(hdc, TRANSPARENT); SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT { let mut rect = RECT {
left: x, left: x,
+2 -2
View File
@@ -23,8 +23,8 @@ pub enum Error {
} }
pub use channel::{current as current_channel, Channel}; pub use channel::{current as current_channel, Channel};
pub use install::{begin, run_cli}; pub use install::run_cli;
pub use release::{fetch_latest, Release, Version}; pub use release::Release;
/// Result of a release-check call. /// Result of a release-check call.
#[derive(Debug)] #[derive(Debug)]
+1 -1
View File
@@ -8,7 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize; use serde::Deserialize;
use crate::creds::{CredentialSource, Locator}; use crate::creds::Locator;
use crate::net::Client; use crate::net::Client;
use crate::usage::{headers, Error, ProviderId, UsageProvider, UsageWindows, Window}; use crate::usage::{headers, Error, ProviderId, UsageProvider, UsageWindows, Window};
+9 -7
View File
@@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize; use serde::Deserialize;
use crate::creds::{CredentialSource, Locator}; use crate::creds::Locator;
use crate::net::Client; use crate::net::Client;
use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window}; use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window};
@@ -67,16 +67,16 @@ impl UsageProvider for ChatGptProvider {
} }
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> { fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
let rl = envelope.rate_limit.flatten()?; let rl = envelope.rate_limit.flatten_box()?;
Some(UsageWindows { Some(UsageWindows {
primary: rl primary: rl
.primary_window .primary_window
.flatten() .flatten_box()
.map(window_from) .map(window_from)
.unwrap_or_default(), .unwrap_or_default(),
secondary: rl secondary: rl
.secondary_window .secondary_window
.flatten() .flatten_box()
.map(window_from) .map(window_from)
.unwrap_or_default(), .unwrap_or_default(),
}) })
@@ -114,12 +114,14 @@ struct ApiWindow {
reset_at: i64, reset_at: i64,
} }
// Helpers used to make `Option<Option<Box<…>>>` flatten cleanly. // Helpers used to make `Option<Option<Box<…>>>` flatten cleanly. We can't
// reuse the std `Option::flatten` name — the inherent method (which returns
// `Option<Box<T>>`) would shadow this trait method.
trait FlattenBoxed<T> { trait FlattenBoxed<T> {
fn flatten(self) -> Option<T>; fn flatten_box(self) -> Option<T>;
} }
impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> { impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
fn flatten(self) -> Option<T> { fn flatten_box(self) -> Option<T> {
self.and_then(|inner| inner.map(|b| *b)) self.and_then(|inner| inner.map(|b| *b))
} }
} }
+3 -1
View File
@@ -12,7 +12,7 @@ pub mod registry;
pub mod types; pub mod types;
pub use registry::Registry; pub use registry::Registry;
pub use types::{ProviderId, ProviderSnapshot, UsageWindows, Window}; pub use types::{ProviderId, UsageWindows, Window};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
@@ -24,6 +24,8 @@ pub enum Error {
TokenExpired, TokenExpired,
#[error("network: {0}")] #[error("network: {0}")]
Network(#[from] crate::net::Error), Network(#[from] crate::net::Error),
#[error("credentials: {0}")]
Creds(#[from] crate::creds::Error),
#[error("unexpected response shape: {0}")] #[error("unexpected response shape: {0}")]
BadResponse(String), BadResponse(String),
} }