mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-15 08:17:00 +00:00
feat(ui): unify usage colors, fix per-bar coloring, round panel corners
Phase 0 of UI/UX polish pass. Surgical changes, no substrate migration yet. - Extract bar_fill_color + accent_color_for into new src/usage_color.rs so the bubble, panel, and tray badge agree on a single 4-band usage ramp. - Panel: color each bar from its own percent (was using max(5h, 7d) for both rows, so a healthy 5h bar turned red whenever 7d was full). - Light-mode amber #B47A20 (was #E0A040, failed WCAG AA at 2.4:1). - Codex identity: switch from white/charcoal to OpenAI teal #10A37F across bubble, panel stripe, and tray sweep so the surfaces share one brand color and the tray badge stops reading as "loading spinner". - Panel: drop WS_BORDER, add DwmSetWindowAttribute(DWMWCP_ROUND) for Win11 rounded corners. Idempotent re-apply on every show() so the attribute survives any future destroy/recreate path. Silently no-ops on Win10.
This commit is contained in:
1 parent
4e0f32591b
commit
a5dec52aaa
6 files changed
+81
-80
No files matched your search
@@ -23,6 +23,7 @@ version = "0.58"
|
|||||||
features = [
|
features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Globalization",
|
"Win32_Globalization",
|
||||||
|
"Win32_Graphics_Dwm",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
|
|||||||
+2
-39
@@ -1205,7 +1205,7 @@ fn row_band(layout: &BarLayout, row_top: i32) -> (i32, i32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: TrayIconKind, is_dark: bool) {
|
fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: TrayIconKind, is_dark: bool) {
|
||||||
let stripe = rgb_to_dib(accent_color_for(model, is_dark));
|
let stripe = rgb_to_dib(crate::usage_color::accent_color_for(model, is_dark));
|
||||||
for y in 0..layout.canvas_h {
|
for y in 0..layout.canvas_h {
|
||||||
for x in 0..layout.accent_right {
|
for x in 0..layout.accent_right {
|
||||||
if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
||||||
@@ -1216,22 +1216,6 @@ fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: TrayIconKi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-provider identity color. Claude = orange. Codex = white-in-dark /
|
|
||||||
/// charcoal-in-light — picking a pure white in light mode would vanish into
|
|
||||||
/// the `#F3F3F3` background, so we mirror to a contrasting neutral.
|
|
||||||
fn accent_color_for(model: TrayIconKind, is_dark: bool) -> Color {
|
|
||||||
match model {
|
|
||||||
ProviderId::Claude => Color::from_hex("#D97757"),
|
|
||||||
ProviderId::ChatGpt => {
|
|
||||||
if is_dark {
|
|
||||||
Color::from_hex("#FFFFFF")
|
|
||||||
} else {
|
|
||||||
Color::from_hex("#2A2A2A")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_bars(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) {
|
fn paint_bars(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) {
|
||||||
let track = if inputs.is_dark {
|
let track = if inputs.is_dark {
|
||||||
Color::from_hex("#3A3A3A")
|
Color::from_hex("#3A3A3A")
|
||||||
@@ -1267,7 +1251,7 @@ fn paint_one_bar(
|
|||||||
if fill_w <= 0 {
|
if fill_w <= 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut accent_rgb = bar_fill_color(inputs.model, inputs.is_dark, p);
|
let mut accent_rgb = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, p);
|
||||||
if p >= 95.0 {
|
if p >= 95.0 {
|
||||||
// Slow brightness triangle: 0.85 → 1.15 over 24 ticks (≈1.9s @ 80ms).
|
// Slow brightness triangle: 0.85 → 1.15 over 24 ticks (≈1.9s @ 80ms).
|
||||||
let t = pulse_triangle(inputs.pulse_phase);
|
let t = pulse_triangle(inputs.pulse_phase);
|
||||||
@@ -1461,27 +1445,6 @@ fn apply_alpha_mask(pixels: &mut [u32], layout: &BarLayout) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
|
|
||||||
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the
|
|
||||||
/// warning bands are the same alarm palette regardless of provider so an
|
|
||||||
/// approaching-limit always looks the same to the eye.
|
|
||||||
///
|
|
||||||
/// - <60% → provider accent (Claude `#D97757` / Codex theme-derived)
|
|
||||||
/// - 60–80% → amber (#E0A040)
|
|
||||||
/// - 80–95% → red (#C45020)
|
|
||||||
/// - ≥95% → deep red (#A01818) — paired with pulse animation
|
|
||||||
pub fn bar_fill_color(model: TrayIconKind, is_dark: bool, percent: f64) -> Color {
|
|
||||||
if percent < 60.0 {
|
|
||||||
accent_color_for(model, is_dark)
|
|
||||||
} else if percent < 80.0 {
|
|
||||||
Color::from_hex("#E0A040")
|
|
||||||
} else if percent < 95.0 {
|
|
||||||
Color::from_hex("#C45020")
|
|
||||||
} else {
|
|
||||||
Color::from_hex("#A01818")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relative luminance of an sRGB color in 0..255 space. Cheap approximation
|
/// Relative luminance of an sRGB color in 0..255 space. Cheap approximation
|
||||||
/// of the Rec. 709 coefficients — good enough for "should the text on this
|
/// of the Rec. 709 coefficients — good enough for "should the text on this
|
||||||
/// pixel be white or black?" decisions.
|
/// pixel be white or black?" decisions.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod os;
|
|||||||
mod tray;
|
mod tray;
|
||||||
mod update;
|
mod update;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
mod usage_color;
|
||||||
|
|
||||||
// Application surface.
|
// Application surface.
|
||||||
mod app;
|
mod app;
|
||||||
|
|||||||
+29
-13
@@ -7,6 +7,9 @@ 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::Dwm::{
|
||||||
|
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||||
|
};
|
||||||
use windows::Win32::Graphics::Gdi::*;
|
use windows::Win32::Graphics::Gdi::*;
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||||
@@ -118,6 +121,8 @@ pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
apply_win11_window_chrome(hwnd);
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = lock_state();
|
let mut guard = lock_state();
|
||||||
if let Some(p) = guard.as_mut() {
|
if let Some(p) = guard.as_mut() {
|
||||||
@@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
|||||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||||
PCWSTR::from_raw(class_w.as_ptr()),
|
PCWSTR::from_raw(class_w.as_ptr()),
|
||||||
PCWSTR::from_raw(title_w.as_ptr()),
|
PCWSTR::from_raw(title_w.as_ptr()),
|
||||||
WS_POPUP | WS_BORDER,
|
WS_POPUP,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
w,
|
w,
|
||||||
@@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
|
||||||
|
/// returns an error on Win10 and earlier, which we deliberately swallow so
|
||||||
|
/// the panel falls back to square corners without complaint. Idempotent:
|
||||||
|
/// DWM ignores redundant identical-value sets, so calling this on every
|
||||||
|
/// `show()` is safe.
|
||||||
|
fn apply_win11_window_chrome(hwnd: HWND) {
|
||||||
|
unsafe {
|
||||||
|
let pref = DWMWCP_ROUND;
|
||||||
|
let _ = DwmSetWindowAttribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||||
|
&pref as *const _ as *const _,
|
||||||
|
std::mem::size_of_val(&pref) as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn hide() {
|
pub fn hide() {
|
||||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||||
if let Some(hwnd) = hwnd_opt {
|
if let Some(hwnd) = hwnd_opt {
|
||||||
@@ -269,21 +291,15 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
} else {
|
} else {
|
||||||
Color::from_hex("#D6D6D6")
|
Color::from_hex("#D6D6D6")
|
||||||
};
|
};
|
||||||
let accent = bar_color_for(data.model, data.session_pct.max(data.weekly_pct), data.is_dark);
|
let session_accent = bar_color_for(data.model, data.session_pct, data.is_dark);
|
||||||
|
let weekly_accent = bar_color_for(data.model, data.weekly_pct, data.is_dark);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let bg_brush = CreateSolidBrush(COLORREF(bg.into_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);
|
||||||
|
|
||||||
// 4-px accent stripe matching the bubble — same provider color so the
|
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
|
||||||
// identity carries across both surfaces. Codex is theme-aware so a
|
|
||||||
// pure white stripe doesn't vanish into the light-mode background.
|
|
||||||
let stripe_color = match (data.model, data.is_dark) {
|
|
||||||
(ProviderId::Claude, _) => Color::from_hex("#D97757"),
|
|
||||||
(ProviderId::ChatGpt, true) => Color::from_hex("#FFFFFF"),
|
|
||||||
(ProviderId::ChatGpt, false) => Color::from_hex("#2A2A2A"),
|
|
||||||
};
|
|
||||||
let stripe_w = scaled(4);
|
let stripe_w = scaled(4);
|
||||||
let stripe_rect = RECT {
|
let stripe_rect = RECT {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -333,7 +349,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
&data.session_text,
|
&data.session_text,
|
||||||
text_color,
|
text_color,
|
||||||
track,
|
track,
|
||||||
accent,
|
session_accent,
|
||||||
dpi,
|
dpi,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -349,7 +365,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
&data.weekly_text,
|
&data.weekly_text,
|
||||||
text_color,
|
text_color,
|
||||||
track,
|
track,
|
||||||
accent,
|
weekly_accent,
|
||||||
dpi,
|
dpi,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -483,7 +499,7 @@ fn draw_text(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
|
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
|
||||||
crate::bubble::bar_fill_color(model, is_dark, percent)
|
crate::usage_color::bar_fill_color(model, is_dark, percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clone_data() -> Option<PanelData> {
|
fn clone_data() -> Option<PanelData> {
|
||||||
|
|||||||
+7
-28
@@ -66,7 +66,7 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
|||||||
if let Some(p) = percent {
|
if let Some(p) = percent {
|
||||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||||
if sweep > 0.0 {
|
if sweep > 0.0 {
|
||||||
let fill = usage_color(p);
|
let fill = usage_color(kind, p);
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||||
paint.anti_alias = true;
|
paint.anti_alias = true;
|
||||||
@@ -116,33 +116,12 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage_color(percent: f64) -> [u8; 3] {
|
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
|
||||||
// Color gradient: soft orange (low usage) → red (near-cap).
|
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
|
||||||
let stops: [(f64, [u8; 3]); 5] = [
|
/// ring readable (Codex sweep stays white instead of charcoal).
|
||||||
(0.0, [0xD9, 0x77, 0x57]),
|
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||||
(50.0, [0xD9, 0x77, 0x57]),
|
let c = crate::usage_color::bar_fill_color(kind, true, percent);
|
||||||
(75.0, [0xCC, 0x8C, 0x20]),
|
[c.r, c.g, c.b]
|
||||||
(90.0, [0xC4, 0x50, 0x20]),
|
|
||||||
(100.0, [0xB8, 0x20, 0x20]),
|
|
||||||
];
|
|
||||||
for pair in stops.windows(2) {
|
|
||||||
let (a_p, a_c) = pair[0];
|
|
||||||
let (b_p, b_c) = pair[1];
|
|
||||||
if percent <= b_p {
|
|
||||||
let span = (b_p - a_p).max(f64::EPSILON);
|
|
||||||
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
|
|
||||||
return [
|
|
||||||
lerp(a_c[0], b_c[0], t),
|
|
||||||
lerp(a_c[1], b_c[1], t),
|
|
||||||
lerp(a_c[2], b_c[2], t),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stops[stops.len() - 1].1
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lerp(a: u8, b: u8, t: f64) -> u8 {
|
|
||||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Pixmap → HICON ----------
|
// ---------- Pixmap → HICON ----------
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Shared usage→color ramp used by the floating bubble, the expanded panel,
|
||||||
|
// and the tray badge. Keeping the function in one place ensures the three
|
||||||
|
// surfaces never disagree about what "78% used" looks like.
|
||||||
|
|
||||||
|
use crate::os::Rgb as Color;
|
||||||
|
use crate::usage::ProviderId;
|
||||||
|
|
||||||
|
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex =
|
||||||
|
/// OpenAI brand teal `#10A37F`, used consistently across dark and light
|
||||||
|
/// themes so the badge/bubble/panel never disagree on Codex identity.
|
||||||
|
pub fn accent_color_for(model: ProviderId, _is_dark: bool) -> Color {
|
||||||
|
match model {
|
||||||
|
ProviderId::Claude => Color::from_hex("#D97757"),
|
||||||
|
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
|
||||||
|
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the
|
||||||
|
/// warning bands are theme-aware so light-mode amber stays readable against
|
||||||
|
/// the `#F3F3F3` background.
|
||||||
|
///
|
||||||
|
/// - <60% → provider accent
|
||||||
|
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
|
||||||
|
/// - 80–95% → red `#C45020`
|
||||||
|
/// - ≥95% → deep red `#A01818` — paired with pulse animation
|
||||||
|
pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
|
||||||
|
if percent < 60.0 {
|
||||||
|
accent_color_for(model, is_dark)
|
||||||
|
} else if percent < 80.0 {
|
||||||
|
if is_dark {
|
||||||
|
Color::from_hex("#E0A040")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#B47A20")
|
||||||
|
}
|
||||||
|
} else if percent < 95.0 {
|
||||||
|
Color::from_hex("#C45020")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#A01818")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user