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:
2026-05-16 11:11:34 +07:00
parent aa6217d2cf
commit ee4e1c9b26
10 changed files with 1839 additions and 149 deletions
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) {
// "…" 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 {
model: kind,
size_logical: settings.bubble_size_logical,
position: settings.bubble_positions.get(kind),
percent: None,
session_pct: None,
session_text: placeholder.clone(),
weekly_pct: None,
weekly_text: placeholder,
is_dark,
});
if hwnd != HWND::default() {
@@ -492,11 +499,18 @@ fn propagate_to_ui() {
for (kind, hwnd) in snap.bubbles.iter() {
let id = kind_to_provider(*kind);
let pct = snap
.snapshots
.get(&id)
.map(|s| s.windows.primary.utilization);
bubble::update_percentage(hwnd.to_hwnd(), pct);
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();
bubble::update_data(
hwnd.to_hwnd(),
session_pct,
session_text,
weekly_pct,
weekly_text,
);
}
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.
// Shape is achieved via per-pixel alpha (alpha=0 outside the circle) and confirmed
// via WM_NCHITTEST returning HTCAPTION inside the circle, HTTRANSPARENT outside.
// The OS handles drag automatically because HTCAPTION inside the circle puts the
// click into the system move loop.
// Shape is achieved via per-pixel alpha (alpha=0 outside the rounded rect) and
// confirmed via WM_NCHITTEST returning HTCAPTION inside the rect, HTTRANSPARENT
// outside. The OS handles drag automatically because HTCAPTION inside the
// 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::ffi::c_void;
@@ -13,7 +18,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
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::Shell::ExtractIconExW;
use windows::Win32::UI::WindowsAndMessaging::*;
@@ -26,10 +31,12 @@ type TrayIconKind = ProviderId;
// ---------- Public types & API ----------
pub const MIN_BUBBLE_SIZE: i32 = 32;
pub const MAX_BUBBLE_SIZE: i32 = 128;
pub const DEFAULT_BUBBLE_SIZE: i32 = 56;
const RESIZE_STEP: i32 = 8;
// Width clamps in logical pixels (height = width / BUBBLE_ASPECT).
pub const MIN_BUBBLE_SIZE: i32 = 120;
pub const MAX_BUBBLE_SIZE: i32 = 360;
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 CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500;
@@ -38,10 +45,17 @@ pub struct BubbleConfig {
pub model: TrayIconKind,
pub size_logical: 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,
}
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
/// `create()` from the UI thread.
pub fn register_class() {
@@ -77,11 +91,11 @@ pub fn create(config: BubbleConfig) -> HWND {
let title_w = wide_str("Claude Code Usage Bubble");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let dpi = primary_dpi();
let size_px = scale_to_dpi(initial_size_logical, dpi);
let (x, y) =
config
.position
.unwrap_or_else(|| default_position(size_px, config.model));
let width_px = scale_to_dpi(initial_size_logical, dpi);
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi);
let (x, y) = config
.position
.unwrap_or_else(|| default_position(width_px, height_px, config.model));
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
PCWSTR::from_raw(class_w.as_ptr()),
@@ -89,8 +103,8 @@ pub fn create(config: BubbleConfig) -> HWND {
WS_POPUP,
x,
y,
size_px,
size_px,
width_px,
height_px,
HWND::default(),
HMENU::default(),
hinstance,
@@ -143,7 +157,10 @@ pub fn create(config: BubbleConfig) -> HWND {
model: config.model,
size_logical: initial_size_logical,
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,
drag_start_pos: None,
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 Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
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);
}
@@ -232,7 +258,10 @@ struct BubbleState {
model: TrayIconKind,
size_logical: i32,
dpi: u32,
percent: Option<f64>,
session_pct: Option<f64>,
session_text: String,
weekly_pct: Option<f64>,
weekly_text: String,
is_dark: bool,
drag_start_pos: Option<(i32, i32)>,
hidden_by_fullscreen: bool,
@@ -363,18 +392,41 @@ fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
return LRESULT(HTNOWHERE as isize);
}
}
let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2;
let radius = ((r.right - r.left) / 2).max(1);
let dx = pt.x - cx;
let dy = pt.y - cy;
if dx * dx + dy * dy <= radius * radius {
let w = r.right - r.left;
let h = r.bottom - r.top;
let radius = corner_radius_px(h);
// Local coordinates relative to top-left of the bubble.
let lx = pt.x - r.left;
let ly = pt.y - r.top;
if point_in_rounded_rect(lx, ly, w, h, radius) {
LRESULT(HTCAPTION as isize)
} else {
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 {
let lo = (lparam.0 & 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;
(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();
unsafe {
let _ = GetWindowRect(hwnd, &mut r);
// Resize centered on existing center.
let cx = (r.left + r.right) / 2;
let cy = (r.top + r.bottom) / 2;
let new_x = cx - size_px / 2;
let new_y = cy - size_px / 2;
let new_x = cx - width_px / 2;
let new_y = cy - height_px / 2;
let _ = SetWindowPos(
hwnd,
HWND::default(),
new_x,
new_y,
size_px,
size_px,
width_px,
height_px,
SWP_NOZORDER | SWP_NOACTIVATE,
);
}
@@ -541,15 +594,72 @@ fn check_fullscreen(bubble_hwnd: HWND) {
// ---------- 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) {
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 Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
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 {
let screen_dc = GetDC(hwnd);
@@ -557,8 +667,8 @@ fn render(hwnd: HWND) {
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size_px,
biHeight: -size_px,
biWidth: width_px,
biHeight: -height_px,
biPlanes: 1,
biBitCount: 32,
biCompression: 0,
@@ -576,30 +686,25 @@ fn render(hwnd: HWND) {
}
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);
paint_background(pixels, size_px, is_dark);
paint_ring(pixels, size_px, percent.unwrap_or(0.0), is_dark);
paint_text(mem_dc, size_px, percent, is_dark, dpi);
// Everything outside the rounded rect stays 0 (fully transparent).
pixels.fill(0);
// Final alpha pass: set alpha=255 inside circle, 0 outside.
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let radius = (size_px / 2) as f64 - 1.0;
let r_sq = radius * radius;
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] |= 0xFF000000;
} else {
pixels[idx] = 0;
}
}
}
paint_background(pixels, width_px, height_px, radius, is_dark);
paint_bars(pixels, width_px, &layout, session_pct, weekly_pct, is_dark);
paint_bar_texts(
mem_dc,
&layout,
&session_text,
&weekly_text,
is_dark,
);
// Final alpha pass: alpha=255 inside the rounded rect, 0 outside. This
// also lifts GDI-drawn text (which leaves alpha=0) into the visible plane.
apply_alpha_mask(pixels, width_px, height_px, radius);
let mut wr = RECT::default();
let _ = GetWindowRect(hwnd, &mut wr);
@@ -609,8 +714,8 @@ fn render(hwnd: HWND) {
};
let pt_src = POINT { x: 0, y: 0 };
let sz = SIZE {
cx: size_px,
cy: size_px,
cx: width_px,
cy: height_px,
};
let blend = BLENDFUNCTION {
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 {
Color::from_hex("#1F1F1F")
} else {
Color::from_hex("#F3F3F3")
};
let bg_bgr = bg.to_colorref();
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let radius = (size_px / 2) as f64 - 1.0;
let r_sq = radius * radius;
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;
let bg_packed = rgb_to_dib(bg);
for y in 0..h {
for x in 0..w {
if point_in_rounded_rect(x, y, w, h, radius) {
pixels[(y * w + x) as usize] = bg_packed;
}
}
}
}
fn paint_ring(pixels: &mut [u32], size_px: i32, percent: f64, is_dark: bool) {
let ring = ring_color_for_percent(percent).to_colorref();
fn paint_bars(
pixels: &mut [u32],
width_px: i32,
layout: &BarLayout,
session_pct: Option<f64>,
weekly_pct: Option<f64>,
is_dark: bool,
) {
let track = if is_dark {
Color::from_hex("#3A3A3A").to_colorref()
rgb_to_dib(Color::from_hex("#3A3A3A"))
} else {
Color::from_hex("#D6D6D6").to_colorref()
rgb_to_dib(Color::from_hex("#D6D6D6"))
};
let cx = (size_px - 1) as f64 / 2.0;
let cy = cx;
let outer = (size_px / 2) as f64 - 1.0;
let thickness = ((size_px as f64) * 0.12).max(3.0);
let inner = outer - thickness;
let inner_sq = inner * inner;
let outer_sq = outer * outer;
let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 2.0 * std::f64::consts::PI;
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 d_sq = dx * dx + dy * dy;
if d_sq <= outer_sq && d_sq >= inner_sq {
// Angle from 12 o'clock, clockwise.
let mut a = dx.atan2(-dy);
if a < 0.0 {
a += 2.0 * std::f64::consts::PI;
}
let idx = (y * size_px + x) as usize;
pixels[idx] = if a <= sweep { ring } else { track };
}
paint_bar(pixels, width_px, layout, layout.row1_y, session_pct, track);
paint_bar(pixels, width_px, layout, layout.row2_y, weekly_pct, track);
}
fn paint_bar(
pixels: &mut [u32],
width_px: i32,
layout: &BarLayout,
top: i32,
pct: Option<f64>,
track: u32,
) {
let bar_w = layout.bar_right - layout.bar_left;
if bar_w <= 0 {
return;
}
// Track first.
for y in top..top + layout.bar_h {
for x in layout.bar_left..layout.bar_right {
pixels[(y * width_px + x) as usize] = 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) {
let text = match percent {
Some(p) => format!("{:.0}%", p),
None => "".to_string(),
};
let mut text_w = wide_str(&text);
fn paint_bar_texts(
hdc: HDC,
layout: &BarLayout,
session_text: &str,
weekly_text: &str,
is_dark: bool,
) {
let text_color = if is_dark {
Color::from_hex("#F5F5F5")
Color::from_hex("#EAEAEA")
} else {
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");
unsafe {
let font = CreateFontW(
font_height,
-font_size.max(8),
0,
0,
0,
FW_SEMIBOLD.0 as i32,
FW_NORMAL.0 as i32,
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()),
);
let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(text_color.to_colorref()));
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: 0,
top: 0,
right: size_px,
bottom: size_px,
};
// Trim the trailing NUL — DrawTextW reads slice length as count.
let len_no_nul = text_w.len().saturating_sub(1);
draw_right_text(hdc, layout, layout.row1_y, session_text);
draw_right_text(hdc, layout, layout.row2_y, weekly_text);
SelectObject(hdc, old_font);
let _ = DeleteObject(font);
}
}
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(
hdc,
&mut text_w[..len_no_nul],
&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
}
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.
// Stagger the Codex bubble above the Claude one if both are enabled.
unsafe {
@@ -810,10 +953,10 @@ fn default_position(size_px: i32, model: TrayIconKind) -> (i32, i32) {
let gap = 24;
let stagger = match model {
ProviderId::Claude => 0,
ProviderId::ChatGpt => size_px + gap,
ProviderId::ChatGpt => height_px + gap,
};
let x = wa.right - size_px - gap;
let y = wa.bottom - size_px - gap - stagger;
let x = wa.right - width_px - gap;
let y = wa.bottom - height_px - gap - stagger;
(x, y)
}
}
+4
View File
@@ -1,4 +1,8 @@
#![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.
mod creds;
+6
View File
@@ -16,6 +16,12 @@ impl Rgb {
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.
pub fn parse_hex(hex: &str) -> Option<Self> {
let s = hex.trim_start_matches('#');
+9 -4
View File
@@ -41,6 +41,11 @@ struct PanelState {
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>> {
static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new();
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);
unsafe {
let bg_brush = CreateSolidBrush(COLORREF(bg.to_colorref()));
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
FillRect(hdc, &rc, bg_brush);
let _ = DeleteObject(bg_brush);
@@ -362,7 +367,7 @@ fn draw_row(
);
unsafe {
let track_brush = CreateSolidBrush(COLORREF(track.to_colorref()));
let track_brush = CreateSolidBrush(COLORREF(track.into_colorref()));
let bar_rect = RECT {
left: bar_x,
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;
if fill_w > 0 {
let accent_brush = CreateSolidBrush(COLORREF(accent.to_colorref()));
let accent_brush = CreateSolidBrush(COLORREF(accent.into_colorref()));
let fill_rect = RECT {
left: bar_x,
top: row_y,
@@ -439,7 +444,7 @@ fn draw_text(
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(color.to_colorref()));
SetTextColor(hdc, COLORREF(color.into_colorref()));
SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: x,
+2 -2
View File
@@ -23,8 +23,8 @@ pub enum Error {
}
pub use channel::{current as current_channel, Channel};
pub use install::{begin, run_cli};
pub use release::{fetch_latest, Release, Version};
pub use install::run_cli;
pub use release::Release;
/// Result of a release-check call.
#[derive(Debug)]
+1 -1
View File
@@ -8,7 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use crate::creds::{CredentialSource, Locator};
use crate::creds::Locator;
use crate::net::Client;
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 crate::creds::{CredentialSource, Locator};
use crate::creds::Locator;
use crate::net::Client;
use crate::usage::{Error, ProviderId, UsageProvider, UsageWindows, Window};
@@ -67,16 +67,16 @@ impl UsageProvider for ChatGptProvider {
}
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
let rl = envelope.rate_limit.flatten()?;
let rl = envelope.rate_limit.flatten_box()?;
Some(UsageWindows {
primary: rl
.primary_window
.flatten()
.flatten_box()
.map(window_from)
.unwrap_or_default(),
secondary: rl
.secondary_window
.flatten()
.flatten_box()
.map(window_from)
.unwrap_or_default(),
})
@@ -114,12 +114,14 @@ struct ApiWindow {
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> {
fn flatten(self) -> Option<T>;
fn flatten_box(self) -> Option<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))
}
}
+3 -1
View File
@@ -12,7 +12,7 @@ pub mod registry;
pub mod types;
pub use registry::Registry;
pub use types::{ProviderId, ProviderSnapshot, UsageWindows, Window};
pub use types::{ProviderId, UsageWindows, Window};
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -24,6 +24,8 @@ pub enum Error {
TokenExpired,
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("credentials: {0}")]
Creds(#[from] crate::creds::Error),
#[error("unexpected response shape: {0}")]
BadResponse(String),
}