diff --git a/Cargo.toml b/Cargo.toml index a10036b..250c0d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ version = "0.58" features = [ "Win32_Foundation", "Win32_Globalization", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Shell", diff --git a/src/bubble.rs b/src/bubble.rs index 6144721..06b4599 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -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) { - 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 x in 0..layout.accent_right { 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) { let track = if inputs.is_dark { Color::from_hex("#3A3A3A") @@ -1267,7 +1251,7 @@ fn paint_one_bar( if fill_w <= 0 { 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 { // Slow brightness triangle: 0.85 → 1.15 over 24 ticks (≈1.9s @ 80ms). 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 /// of the Rec. 709 coefficients — good enough for "should the text on this /// pixel be white or black?" decisions. diff --git a/src/main.rs b/src/main.rs index afbc317..15e20cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod os; mod tray; mod update; mod usage; +mod usage_color; // Application surface. mod app; diff --git a/src/panel.rs b/src/panel.rs index 07711ba..31ec3dd 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -7,6 +7,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; use windows::core::PCWSTR; use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, +}; use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::GetModuleHandleW; 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(); if let Some(p) = guard.as_mut() { @@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option { WS_EX_TOOLWINDOW | WS_EX_TOPMOST, PCWSTR::from_raw(class_w.as_ptr()), PCWSTR::from_raw(title_w.as_ptr()), - WS_POPUP | WS_BORDER, + WS_POPUP, x, y, w, @@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option { } } +/// 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() { let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd); if let Some(hwnd) = hwnd_opt { @@ -269,21 +291,15 @@ fn paint(hwnd: HWND, hdc: HDC) { } else { 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 { let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref())); FillRect(hdc, &rc, bg_brush); let _ = DeleteObject(bg_brush); - // 4-px accent stripe matching the bubble — same provider color so the - // 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_color = crate::usage_color::accent_color_for(data.model, data.is_dark); let stripe_w = scaled(4); let stripe_rect = RECT { left: 0, @@ -333,7 +349,7 @@ fn paint(hwnd: HWND, hdc: HDC) { &data.session_text, text_color, track, - accent, + session_accent, dpi, ); @@ -349,7 +365,7 @@ fn paint(hwnd: HWND, hdc: HDC) { &data.weekly_text, text_color, track, - accent, + weekly_accent, dpi, ); } @@ -483,7 +499,7 @@ fn draw_text( } 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 { diff --git a/src/tray/badge.rs b/src/tray/badge.rs index 499d5a0..22eb5e4 100644 --- a/src/tray/badge.rs +++ b/src/tray/badge.rs @@ -66,7 +66,7 @@ fn render_pixmap(kind: ProviderId, percent: Option) -> Pixmap { if let Some(p) = percent { let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32; if sweep > 0.0 { - let fill = usage_color(p); + let fill = usage_color(kind, p); let mut paint = Paint::default(); paint.set_color_rgba8(fill[0], fill[1], fill[2], 255); paint.anti_alias = true; @@ -116,33 +116,12 @@ fn base_color(kind: ProviderId) -> [u8; 3] { } } -fn usage_color(percent: f64) -> [u8; 3] { - // Color gradient: soft orange (low usage) → red (near-cap). - let stops: [(f64, [u8; 3]); 5] = [ - (0.0, [0xD9, 0x77, 0x57]), - (50.0, [0xD9, 0x77, 0x57]), - (75.0, [0xCC, 0x8C, 0x20]), - (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 +/// Sweep-ring fill color for the tray badge. The badge inner disk is always +/// dark regardless of system theme, so we pass `is_dark = true` to keep the +/// ring readable (Codex sweep stays white instead of charcoal). +fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] { + let c = crate::usage_color::bar_fill_color(kind, true, percent); + [c.r, c.g, c.b] } // ---------- Pixmap → HICON ---------- diff --git a/src/usage_color.rs b/src/usage_color.rs new file mode 100644 index 0000000..bad1697 --- /dev/null +++ b/src/usage_color.rs @@ -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") + } +}