mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-06 14:12:14 +00:00
3c0878f6cc
Saved bubble_positions could land on a secondary monitor that was later disconnected, leaving the bubble created off-screen with no visual feedback on toggle-show. - settings::load now drops any position whose 140px probe rect intersects no connected monitor (MonitorFromRect + MONITOR_DEFAULTTONULL). - bubble::create calls clamp_into_work_area before the first render as a defense-in-depth catch for partial overflows or load/create monitor races. - clamp_into_work_area preserves the Codex-above-Claude stagger from default_position when both bubbles get clamped to the same corner. - Added info/warn log lines on create + clamp paths so future visibility bugs are diagnosable via --diagnose.
1537 lines
50 KiB
Rust
1537 lines
50 KiB
Rust
// 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 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;
|
||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||
|
||
use windows::core::PCWSTR;
|
||
use windows::Win32::Foundation::*;
|
||
use windows::Win32::Graphics::Gdi::*;
|
||
use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
|
||
use windows::Win32::UI::HiDpi::*;
|
||
use windows::Win32::UI::Shell::{
|
||
ExtractIconExW, SHAppBarMessage, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETTASKBARPOS,
|
||
APPBARDATA,
|
||
};
|
||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||
|
||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||
|
||
const TIMER_FULLSCREEN_CHECK: usize = 5;
|
||
const TIMER_PULSE: usize = 6;
|
||
const PULSE_INTERVAL_MS: u32 = 80;
|
||
use crate::usage::ProviderId;
|
||
type TrayIconKind = ProviderId;
|
||
|
||
// ---------- Public types & API ----------
|
||
|
||
// Width clamps in logical pixels. Height is derived per width (see
|
||
// `bubble_height_logical`) — aspect tapers from 3:1 at the small end toward
|
||
// 2.6:1 at the large end so the bars look proportionally chunkier as the
|
||
// bubble grows.
|
||
pub const MIN_BUBBLE_SIZE: i32 = 140;
|
||
pub const MAX_BUBBLE_SIZE: i32 = 360;
|
||
pub const DEFAULT_BUBBLE_SIZE: i32 = 200;
|
||
const RESIZE_STEP: i32 = 20;
|
||
const SNAP_ZONE_LOGICAL: i32 = 12;
|
||
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
|
||
const CORNER_INSET_LOGICAL: i32 = 12;
|
||
const TASKBAR_GAP_LOGICAL: i32 = 4;
|
||
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
|
||
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
||
const FULLSCREEN_POLL_MS: u32 = 1500;
|
||
|
||
/// Per-width breakpoint defining bar height, text font size, and row gap in
|
||
/// *logical* pixels. Picked so that the smallest bubble is still legible and
|
||
/// the largest doesn't waste vertical space.
|
||
#[derive(Clone, Copy)]
|
||
struct Breakpoint {
|
||
bar_h: i32,
|
||
font: i32,
|
||
row_gap: i32,
|
||
}
|
||
|
||
fn breakpoint_for_width_logical(w: i32) -> Breakpoint {
|
||
if w <= 140 {
|
||
Breakpoint { bar_h: 12, font: 11, row_gap: 4 }
|
||
} else if w <= 200 {
|
||
Breakpoint { bar_h: 16, font: 13, row_gap: 6 }
|
||
} else if w <= 280 {
|
||
Breakpoint { bar_h: 20, font: 15, row_gap: 8 }
|
||
} else {
|
||
Breakpoint { bar_h: 24, font: 17, row_gap: 10 }
|
||
}
|
||
}
|
||
|
||
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
|
||
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
|
||
/// grows so the wider bars don't look anaemic.
|
||
fn aspect_at_width(w_logical: i32) -> (i32, i32) {
|
||
if w_logical <= 200 {
|
||
(3, 1)
|
||
} else if w_logical <= 280 {
|
||
(14, 5) // 2.8 : 1
|
||
} else {
|
||
(13, 5) // 2.6 : 1
|
||
}
|
||
}
|
||
|
||
pub struct BubbleConfig {
|
||
pub model: TrayIconKind,
|
||
pub size_logical: i32,
|
||
pub position: Option<(i32, i32)>,
|
||
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 {
|
||
let (num, den) = aspect_at_width(width_logical);
|
||
((width_logical * den) / num).max(20)
|
||
}
|
||
|
||
/// Register the bubble window class. Idempotent; safe to call before the first
|
||
/// `create()` from the UI thread.
|
||
pub fn register_class() {
|
||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||
REGISTERED.get_or_init(|| unsafe {
|
||
let class_w = wide_str(CLASS_NAME);
|
||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||
let wc = WNDCLASSEXW {
|
||
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||
style: CS_HREDRAW | CS_VREDRAW,
|
||
lpfnWndProc: Some(wnd_proc),
|
||
hInstance: HINSTANCE(hinstance.0),
|
||
hCursor: LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(),
|
||
hbrBackground: HBRUSH(std::ptr::null_mut()),
|
||
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
|
||
..Default::default()
|
||
};
|
||
if RegisterClassExW(&wc) == 0 {
|
||
log::error!("bubble RegisterClassExW returned 0");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Create a bubble window. Returns the HWND. The caller (app::run) owns the
|
||
/// message-loop dispatch.
|
||
pub fn create(config: BubbleConfig) -> HWND {
|
||
register_class();
|
||
let initial_size_logical = config
|
||
.size_logical
|
||
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||
let dpi_for_create = primary_dpi();
|
||
let width_px = scale_to_dpi(initial_size_logical, dpi_for_create);
|
||
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create);
|
||
let (x, y) = config
|
||
.position
|
||
.unwrap_or_else(|| default_position(width_px, height_px, config.model));
|
||
let hwnd = unsafe {
|
||
let class_w = wide_str(CLASS_NAME);
|
||
let title_w = wide_str("Claude Code Usage Bubble");
|
||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||
CreateWindowExW(
|
||
WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE,
|
||
PCWSTR::from_raw(class_w.as_ptr()),
|
||
PCWSTR::from_raw(title_w.as_ptr()),
|
||
WS_POPUP,
|
||
x,
|
||
y,
|
||
width_px,
|
||
height_px,
|
||
HWND::default(),
|
||
HMENU::default(),
|
||
hinstance,
|
||
None,
|
||
)
|
||
.unwrap_or_default()
|
||
};
|
||
|
||
if hwnd == HWND::default() {
|
||
log::error!("bubble CreateWindowExW failed");
|
||
return hwnd;
|
||
}
|
||
|
||
// Embed app icon in window non-client (mostly cosmetic; toolwindows
|
||
// don't show captions but the icon helps in dev tooling).
|
||
unsafe {
|
||
let mut large_icon = HICON::default();
|
||
let mut small_icon = HICON::default();
|
||
let mut exe = [0u16; 260];
|
||
GetModuleFileNameW(HMODULE::default(), &mut exe);
|
||
let _ = ExtractIconExW(
|
||
PCWSTR::from_raw(exe.as_ptr()),
|
||
0,
|
||
Some(&mut large_icon),
|
||
Some(&mut small_icon),
|
||
1,
|
||
);
|
||
if !large_icon.is_invalid() {
|
||
let _ = SendMessageW(
|
||
hwnd,
|
||
WM_SETICON,
|
||
WPARAM(ICON_BIG as usize),
|
||
LPARAM(large_icon.0 as isize),
|
||
);
|
||
}
|
||
if !small_icon.is_invalid() {
|
||
let _ = SendMessageW(
|
||
hwnd,
|
||
WM_SETICON,
|
||
WPARAM(ICON_SMALL as usize),
|
||
LPARAM(small_icon.0 as isize),
|
||
);
|
||
}
|
||
}
|
||
|
||
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
|
||
lock_bubbles().insert(
|
||
hwnd.0 as isize,
|
||
BubbleState {
|
||
model: config.model,
|
||
size_logical: initial_size_logical,
|
||
dpi,
|
||
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,
|
||
user_hidden: false,
|
||
pulse_phase: 0,
|
||
pulse_timer_armed: false,
|
||
},
|
||
);
|
||
|
||
log::info!(
|
||
"bubble create model={:?} pos=({x},{y}) size={width_px}x{height_px} dpi={dpi}",
|
||
config.model
|
||
);
|
||
|
||
// Defense in depth: settings::load already validates positions against
|
||
// currently-connected monitors, but a monitor unplug between load and
|
||
// create (or a partially-off-screen saved position) is still possible.
|
||
clamp_into_work_area(hwnd);
|
||
|
||
render(hwnd);
|
||
unsafe {
|
||
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||
// Periodic fullscreen-foreground check.
|
||
SetTimer(hwnd, TIMER_FULLSCREEN_CHECK, FULLSCREEN_POLL_MS, None);
|
||
}
|
||
|
||
hwnd
|
||
}
|
||
|
||
pub fn destroy(hwnd: HWND) {
|
||
unsafe {
|
||
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
|
||
let _ = KillTimer(hwnd, TIMER_PULSE);
|
||
let _ = DestroyWindow(hwnd);
|
||
}
|
||
}
|
||
|
||
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.session_pct = session_pct;
|
||
b.session_text = session_text;
|
||
b.weekly_pct = weekly_pct;
|
||
b.weekly_text = weekly_text;
|
||
}
|
||
sync_pulse_timer(hwnd);
|
||
render(hwnd);
|
||
}
|
||
|
||
fn any_pct_in_alarm(b: &BubbleState) -> bool {
|
||
b.session_pct.is_some_and(|p| p >= 95.0) || b.weekly_pct.is_some_and(|p| p >= 95.0)
|
||
}
|
||
|
||
fn sync_pulse_timer(hwnd: HWND) {
|
||
let (should_be_armed, currently_armed) = {
|
||
let bubbles = lock_bubbles();
|
||
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
|
||
return;
|
||
};
|
||
(any_pct_in_alarm(b), b.pulse_timer_armed)
|
||
};
|
||
if should_be_armed == currently_armed {
|
||
return;
|
||
}
|
||
unsafe {
|
||
if should_be_armed {
|
||
SetTimer(hwnd, TIMER_PULSE, PULSE_INTERVAL_MS, None);
|
||
} else {
|
||
let _ = KillTimer(hwnd, TIMER_PULSE);
|
||
}
|
||
}
|
||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||
b.pulse_timer_armed = should_be_armed;
|
||
if !should_be_armed {
|
||
b.pulse_phase = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
|
||
{
|
||
let mut bubbles = lock_bubbles();
|
||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||
return;
|
||
};
|
||
b.is_dark = is_dark;
|
||
}
|
||
render(hwnd);
|
||
}
|
||
|
||
pub fn set_user_visible(hwnd: HWND, visible: bool) {
|
||
{
|
||
let mut bubbles = lock_bubbles();
|
||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||
return;
|
||
};
|
||
b.user_hidden = !visible;
|
||
}
|
||
unsafe {
|
||
let cmd = if visible { SW_SHOWNOACTIVATE } else { SW_HIDE };
|
||
let _ = ShowWindow(hwnd, cmd);
|
||
}
|
||
// A layered window's composited surface is dropped while hidden, so
|
||
// ShowWindow(SW_SHOWNOACTIVATE) on its own renders blank until the next
|
||
// UpdateLayeredWindow. The cached BubbleState (pcts + texts) hasn't gone
|
||
// anywhere, so just re-paint from it so the bubble pops back with the
|
||
// last good data instead of empty placeholders.
|
||
if visible {
|
||
render(hwnd);
|
||
}
|
||
}
|
||
|
||
pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
|
||
let mut r = RECT::default();
|
||
unsafe {
|
||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||
return None;
|
||
}
|
||
}
|
||
Some((r.left, r.top))
|
||
}
|
||
|
||
pub fn model(hwnd: HWND) -> Option<TrayIconKind> {
|
||
lock_bubbles()
|
||
.get(&(hwnd.0 as isize))
|
||
.map(|b| b.model)
|
||
}
|
||
|
||
pub fn size_logical(hwnd: HWND) -> Option<i32> {
|
||
lock_bubbles()
|
||
.get(&(hwnd.0 as isize))
|
||
.map(|b| b.size_logical)
|
||
}
|
||
|
||
// ---------- State ----------
|
||
|
||
struct BubbleState {
|
||
model: TrayIconKind,
|
||
size_logical: i32,
|
||
dpi: u32,
|
||
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,
|
||
user_hidden: bool,
|
||
/// Frame counter for the ≥95% pulse animation. Increments on each
|
||
/// TIMER_PULSE tick when at least one bar is in the alarm band.
|
||
pulse_phase: u32,
|
||
/// Whether TIMER_PULSE is currently armed for this bubble.
|
||
pulse_timer_armed: bool,
|
||
}
|
||
|
||
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
|
||
static BUBBLES: OnceLock<Mutex<HashMap<isize, BubbleState>>> = OnceLock::new();
|
||
BUBBLES.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
fn lock_bubbles() -> MutexGuard<'static, HashMap<isize, BubbleState>> {
|
||
bubbles().lock().expect("bubble state mutex poisoned")
|
||
}
|
||
|
||
// ---------- Window proc ----------
|
||
|
||
unsafe extern "system" fn wnd_proc(
|
||
hwnd: HWND,
|
||
msg: u32,
|
||
wparam: WPARAM,
|
||
lparam: LPARAM,
|
||
) -> LRESULT {
|
||
match msg {
|
||
WM_NCHITTEST => hit_test(hwnd, lparam),
|
||
WM_ENTERSIZEMOVE => {
|
||
let mut r = RECT::default();
|
||
let _ = GetWindowRect(hwnd, &mut r);
|
||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||
b.drag_start_pos = Some((r.left, r.top));
|
||
}
|
||
LRESULT(0)
|
||
}
|
||
WM_EXITSIZEMOVE => {
|
||
// WM_NCLBUTTONUP isn't reliably delivered for HTCAPTION drags; instead
|
||
// we infer click-vs-drag from whether the window actually moved.
|
||
let start = {
|
||
let mut bubbles = lock_bubbles();
|
||
let start = bubbles
|
||
.get(&(hwnd.0 as isize))
|
||
.and_then(|b| b.drag_start_pos);
|
||
if let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) {
|
||
b.drag_start_pos = None;
|
||
}
|
||
start
|
||
};
|
||
let mut current = RECT::default();
|
||
let _ = GetWindowRect(hwnd, &mut current);
|
||
let moved = match start {
|
||
Some((sx, sy)) => (current.left - sx).abs() >= 3 || (current.top - sy).abs() >= 3,
|
||
None => false,
|
||
};
|
||
if moved {
|
||
snap_to_edge(hwnd);
|
||
if let Some(model) = model(hwnd) {
|
||
if let Some(pos) = position(hwnd) {
|
||
crate::app::on_bubble_moved(model, pos);
|
||
}
|
||
}
|
||
} else if let Some(model) = model(hwnd) {
|
||
crate::app::on_bubble_click(hwnd, model);
|
||
}
|
||
LRESULT(0)
|
||
}
|
||
WM_NCRBUTTONUP => {
|
||
if let Some(model) = model(hwnd) {
|
||
let pt = lparam_to_point(lparam);
|
||
crate::app::on_bubble_right_click(hwnd, model, pt);
|
||
}
|
||
LRESULT(0)
|
||
}
|
||
WM_MOUSEWHEEL => {
|
||
let modifiers = (wparam.0 & 0xFFFF) as u32;
|
||
const MK_CONTROL: u32 = 0x0008;
|
||
if modifiers & MK_CONTROL != 0 {
|
||
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
|
||
let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP };
|
||
resize_step(hwnd, step);
|
||
LRESULT(0)
|
||
} else {
|
||
DefWindowProcW(hwnd, msg, wparam, lparam)
|
||
}
|
||
}
|
||
WM_DPICHANGED => {
|
||
let new_dpi = ((wparam.0 >> 16) & 0xFFFF) as u32;
|
||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||
b.dpi = new_dpi;
|
||
}
|
||
let rect_ptr = lparam.0 as *const RECT;
|
||
if !rect_ptr.is_null() {
|
||
let r = *rect_ptr;
|
||
let _ = SetWindowPos(
|
||
hwnd,
|
||
HWND::default(),
|
||
r.left,
|
||
r.top,
|
||
r.right - r.left,
|
||
r.bottom - r.top,
|
||
SWP_NOZORDER | SWP_NOACTIVATE,
|
||
);
|
||
}
|
||
render(hwnd);
|
||
LRESULT(0)
|
||
}
|
||
WM_TIMER => {
|
||
match wparam.0 {
|
||
w if w == TIMER_FULLSCREEN_CHECK => check_fullscreen(hwnd),
|
||
w if w == TIMER_PULSE => {
|
||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||
b.pulse_phase = b.pulse_phase.wrapping_add(1);
|
||
}
|
||
render(hwnd);
|
||
}
|
||
_ => {}
|
||
}
|
||
LRESULT(0)
|
||
}
|
||
WM_COMMAND => {
|
||
crate::app::on_menu_command(wparam.0 as u32, hwnd);
|
||
LRESULT(0)
|
||
}
|
||
WM_SETTINGCHANGE => {
|
||
// Taskbar move / auto-hide toggle / DPI change / theme toggle
|
||
// all post this. Re-clamp into the new work area (bubble must
|
||
// not end up hidden behind the new taskbar position) and ask
|
||
// the app to re-read the light/dark setting — Windows fires
|
||
// this message when the user flips the OS theme in Settings.
|
||
clamp_into_work_area(hwnd);
|
||
crate::app::recheck_theme();
|
||
LRESULT(0)
|
||
}
|
||
WM_DESTROY => {
|
||
lock_bubbles().remove(&(hwnd.0 as isize));
|
||
LRESULT(0)
|
||
}
|
||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||
}
|
||
}
|
||
|
||
fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
|
||
let pt = lparam_to_point(lparam);
|
||
let mut r = RECT::default();
|
||
unsafe {
|
||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||
return LRESULT(HTNOWHERE as isize);
|
||
}
|
||
}
|
||
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;
|
||
POINT { x: lo, y: hi }
|
||
}
|
||
|
||
// ---------- Resize / snap ----------
|
||
|
||
fn resize_step(hwnd: HWND, delta: i32) {
|
||
let (new_logical, dpi) = {
|
||
let mut bubbles = lock_bubbles();
|
||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||
return;
|
||
};
|
||
let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||
if new_logical == b.size_logical {
|
||
return;
|
||
}
|
||
b.size_logical = new_logical;
|
||
(new_logical, b.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 - width_px / 2;
|
||
let new_y = cy - height_px / 2;
|
||
let _ = SetWindowPos(
|
||
hwnd,
|
||
HWND::default(),
|
||
new_x,
|
||
new_y,
|
||
width_px,
|
||
height_px,
|
||
SWP_NOZORDER | SWP_NOACTIVATE,
|
||
);
|
||
}
|
||
render(hwnd);
|
||
if let Some(m) = model(hwnd) {
|
||
crate::app::on_bubble_resized(m, new_logical);
|
||
}
|
||
}
|
||
|
||
fn snap_to_edge(hwnd: HWND) {
|
||
let dpi = lock_bubbles()
|
||
.get(&(hwnd.0 as isize))
|
||
.map(|b| b.dpi)
|
||
.unwrap_or(96);
|
||
let edge_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi);
|
||
let corner_zone = scale_to_dpi(CORNER_SNAP_ZONE_LOGICAL, dpi);
|
||
let corner_inset = scale_to_dpi(CORNER_INSET_LOGICAL, dpi);
|
||
let taskbar_gap = scale_to_dpi(TASKBAR_GAP_LOGICAL, dpi);
|
||
let peer_tolerance = scale_to_dpi(PEER_ALIGN_TOLERANCE_LOGICAL, dpi);
|
||
|
||
let mut r = RECT::default();
|
||
let monitor;
|
||
unsafe {
|
||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||
return;
|
||
}
|
||
monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||
}
|
||
if monitor.is_invalid() {
|
||
return;
|
||
}
|
||
let mut info = MONITORINFO {
|
||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||
..Default::default()
|
||
};
|
||
unsafe {
|
||
if !GetMonitorInfoW(monitor, &mut info).as_bool() {
|
||
return;
|
||
}
|
||
}
|
||
let wa = info.rcWork;
|
||
let w = r.right - r.left;
|
||
let h = r.bottom - r.top;
|
||
let mut nx = r.left;
|
||
let mut ny = r.top;
|
||
|
||
// 1. Corner snap — if the bubble's nearest-corner distance is under the
|
||
// 32-px corner zone, slam it into the corner with the 12-px inset.
|
||
let snapped_to_corner = try_corner_snap(&mut nx, &mut ny, &wa, w, h, corner_zone, corner_inset);
|
||
|
||
if !snapped_to_corner {
|
||
// 2. Edge snap (existing behavior) — also handles taskbar-adjacency
|
||
// when the taskbar steals from the work area on the same edge.
|
||
let taskbar = read_taskbar();
|
||
snap_to_work_area_edges(&mut nx, &mut ny, &wa, w, h, edge_zone);
|
||
if let Some(tb) = taskbar {
|
||
snap_alongside_taskbar(&mut nx, &mut ny, &tb, &wa, w, h, edge_zone, taskbar_gap);
|
||
}
|
||
|
||
// 3. Peer vertical alignment — when the other bubble is within ±8 px
|
||
// on Y, snap the dragged bubble to share its baseline.
|
||
align_with_peer(hwnd, &mut ny, peer_tolerance);
|
||
}
|
||
|
||
// Clamp into the work area in any case (so the bubble can't be lost off-screen).
|
||
nx = nx.clamp(wa.left, (wa.right - w).max(wa.left));
|
||
ny = ny.clamp(wa.top, (wa.bottom - h).max(wa.top));
|
||
|
||
if nx != r.left || ny != r.top {
|
||
unsafe {
|
||
let _ = SetWindowPos(
|
||
hwnd,
|
||
HWND::default(),
|
||
nx,
|
||
ny,
|
||
0,
|
||
0,
|
||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn try_corner_snap(
|
||
nx: &mut i32,
|
||
ny: &mut i32,
|
||
wa: &RECT,
|
||
w: i32,
|
||
h: i32,
|
||
zone: i32,
|
||
inset: i32,
|
||
) -> bool {
|
||
// Distance from each work-area corner to the bubble's nearest corner.
|
||
let tl = (*nx - wa.left).abs() + (*ny - wa.top).abs();
|
||
let tr = (wa.right - (*nx + w)).abs() + (*ny - wa.top).abs();
|
||
let bl = (*nx - wa.left).abs() + (wa.bottom - (*ny + h)).abs();
|
||
let br = (wa.right - (*nx + w)).abs() + (wa.bottom - (*ny + h)).abs();
|
||
let min = tl.min(tr).min(bl).min(br);
|
||
if min > zone * 2 {
|
||
return false;
|
||
}
|
||
if min == tl {
|
||
*nx = wa.left + inset;
|
||
*ny = wa.top + inset;
|
||
} else if min == tr {
|
||
*nx = wa.right - inset - w;
|
||
*ny = wa.top + inset;
|
||
} else if min == bl {
|
||
*nx = wa.left + inset;
|
||
*ny = wa.bottom - inset - h;
|
||
} else {
|
||
*nx = wa.right - inset - w;
|
||
*ny = wa.bottom - inset - h;
|
||
}
|
||
true
|
||
}
|
||
|
||
fn snap_to_work_area_edges(nx: &mut i32, ny: &mut i32, wa: &RECT, w: i32, h: i32, zone: i32) {
|
||
if (*nx - wa.left).abs() < zone {
|
||
*nx = wa.left;
|
||
} else if (wa.right - (*nx + w)).abs() < zone {
|
||
*nx = wa.right - w;
|
||
}
|
||
if (*ny - wa.top).abs() < zone {
|
||
*ny = wa.top;
|
||
} else if (wa.bottom - (*ny + h)).abs() < zone {
|
||
*ny = wa.bottom - h;
|
||
}
|
||
}
|
||
|
||
struct Taskbar {
|
||
rect: RECT,
|
||
edge: u32,
|
||
}
|
||
|
||
fn read_taskbar() -> Option<Taskbar> {
|
||
let mut abd = APPBARDATA {
|
||
cbSize: std::mem::size_of::<APPBARDATA>() as u32,
|
||
..Default::default()
|
||
};
|
||
let res = unsafe { SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd) };
|
||
if res == 0 {
|
||
return None;
|
||
}
|
||
Some(Taskbar {
|
||
rect: abd.rc,
|
||
edge: abd.uEdge,
|
||
})
|
||
}
|
||
|
||
fn snap_alongside_taskbar(
|
||
nx: &mut i32,
|
||
ny: &mut i32,
|
||
tb: &Taskbar,
|
||
wa: &RECT,
|
||
w: i32,
|
||
h: i32,
|
||
zone: i32,
|
||
gap: i32,
|
||
) {
|
||
// Only snap on the taskbar's docked edge. The bubble docks against the
|
||
// inner face of the taskbar with a 4-px gap so it visually leans on it.
|
||
match tb.edge {
|
||
e if e == ABE_BOTTOM => {
|
||
let target = tb.rect.top - gap - h;
|
||
if (*ny - target).abs() < zone {
|
||
*ny = target.max(wa.top);
|
||
}
|
||
}
|
||
e if e == ABE_TOP => {
|
||
let target = tb.rect.bottom + gap;
|
||
if (*ny - target).abs() < zone {
|
||
*ny = target.min(wa.bottom - h);
|
||
}
|
||
}
|
||
e if e == ABE_LEFT => {
|
||
let target = tb.rect.right + gap;
|
||
if (*nx - target).abs() < zone {
|
||
*nx = target.min(wa.right - w);
|
||
}
|
||
}
|
||
e if e == ABE_RIGHT => {
|
||
let target = tb.rect.left - gap - w;
|
||
if (*nx - target).abs() < zone {
|
||
*nx = target.max(wa.left);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn clamp_into_work_area(hwnd: HWND) {
|
||
let mut r = RECT::default();
|
||
let monitor;
|
||
unsafe {
|
||
if GetWindowRect(hwnd, &mut r).is_err() {
|
||
return;
|
||
}
|
||
monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||
}
|
||
if monitor.is_invalid() {
|
||
return;
|
||
}
|
||
let mut info = MONITORINFO {
|
||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||
..Default::default()
|
||
};
|
||
unsafe {
|
||
if !GetMonitorInfoW(monitor, &mut info).as_bool() {
|
||
return;
|
||
}
|
||
}
|
||
let wa = info.rcWork;
|
||
let w = r.right - r.left;
|
||
let h = r.bottom - r.top;
|
||
let nx = r.left.clamp(wa.left, (wa.right - w).max(wa.left));
|
||
let mut ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top));
|
||
|
||
// When both bubbles get clamped to the same bottom-right corner (e.g.,
|
||
// saved positions were on a disconnected monitor and the validator missed
|
||
// them), keep the Codex-above-Claude stagger that `default_position` uses
|
||
// so they don't visually stack.
|
||
let is_codex = lock_bubbles()
|
||
.get(&(hwnd.0 as isize))
|
||
.is_some_and(|b| matches!(b.model, TrayIconKind::ChatGpt));
|
||
if is_codex && nx == wa.right - w && ny == wa.bottom - h {
|
||
const STAGGER_GAP: i32 = 24;
|
||
ny = (ny - h - STAGGER_GAP).max(wa.top);
|
||
}
|
||
|
||
if nx != r.left || ny != r.top {
|
||
log::warn!(
|
||
"clamp_into_work_area moved bubble from ({}, {}) to ({nx}, {ny})",
|
||
r.left,
|
||
r.top
|
||
);
|
||
unsafe {
|
||
let _ = SetWindowPos(
|
||
hwnd,
|
||
HWND::default(),
|
||
nx,
|
||
ny,
|
||
0,
|
||
0,
|
||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) {
|
||
let bubbles = lock_bubbles();
|
||
for (id, _) in bubbles.iter() {
|
||
if *id == this_hwnd.0 as isize {
|
||
continue;
|
||
}
|
||
let peer_hwnd = HWND(*id as *mut c_void);
|
||
let mut pr = RECT::default();
|
||
unsafe {
|
||
if GetWindowRect(peer_hwnd, &mut pr).is_err() {
|
||
continue;
|
||
}
|
||
}
|
||
if (*ny - pr.top).abs() <= tolerance {
|
||
*ny = pr.top;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Fullscreen detection ----------
|
||
|
||
fn check_fullscreen(bubble_hwnd: HWND) {
|
||
let fg = unsafe { GetForegroundWindow() };
|
||
if fg == HWND::default() || fg == bubble_hwnd {
|
||
return;
|
||
}
|
||
let mut fr = RECT::default();
|
||
unsafe {
|
||
if GetWindowRect(fg, &mut fr).is_err() {
|
||
return;
|
||
}
|
||
}
|
||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||
if monitor.is_invalid() {
|
||
return;
|
||
}
|
||
let mut info = MONITORINFO {
|
||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||
..Default::default()
|
||
};
|
||
let ok = unsafe { GetMonitorInfoW(monitor, &mut info).as_bool() };
|
||
if !ok {
|
||
return;
|
||
}
|
||
let mr = info.rcMonitor;
|
||
let is_fullscreen =
|
||
fr.left <= mr.left && fr.top <= mr.top && fr.right >= mr.right && fr.bottom >= mr.bottom;
|
||
|
||
let (was_hidden_by_fs, user_hidden) = {
|
||
let bubbles = lock_bubbles();
|
||
let Some(b) = bubbles.get(&(bubble_hwnd.0 as isize)) else {
|
||
return;
|
||
};
|
||
(b.hidden_by_fullscreen, b.user_hidden)
|
||
};
|
||
|
||
if is_fullscreen && !was_hidden_by_fs {
|
||
unsafe {
|
||
let _ = ShowWindow(bubble_hwnd, SW_HIDE);
|
||
}
|
||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||
b.hidden_by_fullscreen = true;
|
||
}
|
||
} else if !is_fullscreen && was_hidden_by_fs {
|
||
if !user_hidden {
|
||
unsafe {
|
||
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
|
||
}
|
||
// Re-paint so the layered surface has the cached data again
|
||
// (see comment in `set_user_visible`).
|
||
render(bubble_hwnd);
|
||
}
|
||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||
b.hidden_by_fullscreen = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Painting ----------
|
||
|
||
const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
|
||
const LABEL_PAD_LOGICAL: i32 = 6;
|
||
// Sized for the widest countdown across all shipped locales. Korean
|
||
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
|
||
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
|
||
// out of the column. Update this when adding a locale with a longer
|
||
// suffix.
|
||
const COUNTDOWN_TEMPLATE: &str = "999시간";
|
||
// Percent now lives in its own column between the bar and the countdown so
|
||
// the two numeric readouts ("44%" and "3h") sit next to each other for
|
||
// quick scanning, and the percent never has to fight the bar's fill colour
|
||
// for contrast.
|
||
const PERCENT_TEMPLATE: &str = "100%";
|
||
|
||
struct BarLayout {
|
||
/// Bubble width in pixels.
|
||
canvas_w: i32,
|
||
/// Bubble height in pixels.
|
||
canvas_h: i32,
|
||
/// Corner radius of the rounded rectangle.
|
||
corner_radius: i32,
|
||
/// Accent stripe (left edge) in pixels — Claude orange or Codex neutral.
|
||
accent_right: i32,
|
||
/// Row label column ("5h" / "7d").
|
||
label_left: i32,
|
||
label_right: i32,
|
||
/// Percent column ("44%"), rendered on the bubble background.
|
||
percent_left: i32,
|
||
percent_right: i32,
|
||
/// Bar geometry.
|
||
bar_left: i32,
|
||
bar_right: i32,
|
||
bar_h: i32,
|
||
/// Right-side countdown text column.
|
||
right_text_left: i32,
|
||
right_text_right: i32,
|
||
/// Vertical positions (top edge of each row's bar).
|
||
row1_y: i32,
|
||
row2_y: i32,
|
||
/// Font size for the main text (percent + countdown).
|
||
font_px: i32,
|
||
/// Font size for the muted row labels — a notch smaller than `font_px`.
|
||
label_font_px: i32,
|
||
}
|
||
|
||
fn compute_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BarLayout {
|
||
let bp = breakpoint_for_width_logical(size_logical);
|
||
let width_px = scale_to_dpi(size_logical, dpi);
|
||
let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi);
|
||
let bar_h = scale_to_dpi(bp.bar_h, dpi);
|
||
let row_gap = scale_to_dpi(bp.row_gap, dpi);
|
||
let pad_x = scale_to_dpi(10, dpi);
|
||
let pad_y = ((height_px - bar_h * 2 - row_gap) / 2).max(scale_to_dpi(6, dpi));
|
||
let accent_w = scale_to_dpi(ACCENT_STRIPE_W_LOGICAL, dpi);
|
||
let label_pad = scale_to_dpi(LABEL_PAD_LOGICAL, dpi);
|
||
let corner_radius = scale_to_dpi((bp.bar_h + bp.row_gap).max(8), dpi).min(height_px / 2);
|
||
|
||
// Measure the worst-case strings against the real font so the columns are
|
||
// exactly wide enough — no more, no less.
|
||
let font_px = scale_to_dpi(bp.font, dpi).max(scale_to_dpi(11, dpi));
|
||
let label_font_px = scale_to_dpi(bp.font - 2, dpi).max(scale_to_dpi(9, dpi));
|
||
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, font_px);
|
||
let percent_w = measure_text_w(mem_dc, PERCENT_TEMPLATE, font_px);
|
||
let label_w = measure_text_w(mem_dc, "5h", label_font_px)
|
||
.max(measure_text_w(mem_dc, "7d", label_font_px));
|
||
|
||
// Layout (left → right):
|
||
// [accent] [pad] [label] [pad] [bar] [pad] [percent] [pad] [countdown] [pad_x]
|
||
let accent_left = 0;
|
||
let accent_right = accent_left + accent_w;
|
||
let label_left = accent_right + label_pad;
|
||
let label_right = label_left + label_w;
|
||
let bar_left = label_right + label_pad;
|
||
|
||
let right_text_right = width_px - pad_x;
|
||
let right_text_left = (right_text_right - countdown_w).max(bar_left + scale_to_dpi(20, dpi));
|
||
let percent_right = (right_text_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
||
let percent_left = (percent_right - percent_w).max(bar_left + scale_to_dpi(20, dpi));
|
||
let bar_right = (percent_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
||
|
||
let row1_y = pad_y;
|
||
let row2_y = pad_y + bar_h + row_gap;
|
||
|
||
BarLayout {
|
||
canvas_w: width_px,
|
||
canvas_h: height_px,
|
||
corner_radius,
|
||
accent_right,
|
||
label_left,
|
||
label_right,
|
||
percent_left,
|
||
percent_right,
|
||
bar_left,
|
||
bar_right,
|
||
bar_h,
|
||
right_text_left,
|
||
right_text_right,
|
||
row1_y,
|
||
row2_y,
|
||
font_px,
|
||
label_font_px,
|
||
}
|
||
}
|
||
|
||
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");
|
||
let mut w: Vec<u16> = text.encode_utf16().collect();
|
||
unsafe {
|
||
let font = CreateFontW(
|
||
-font_height_px,
|
||
0,
|
||
0,
|
||
0,
|
||
FW_NORMAL.0 as i32,
|
||
0,
|
||
0,
|
||
0,
|
||
DEFAULT_CHARSET.0 as u32,
|
||
OUT_DEFAULT_PRECIS.0 as u32,
|
||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||
CLEARTYPE_QUALITY.0 as u32,
|
||
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
|
||
PCWSTR::from_raw(font_name.as_ptr()),
|
||
);
|
||
let old = SelectObject(hdc, font);
|
||
let mut size = SIZE::default();
|
||
let _ = GetTextExtentPoint32W(hdc, &mut w, &mut size);
|
||
SelectObject(hdc, old);
|
||
let _ = DeleteObject(font);
|
||
size.cx
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
struct PaintInputs {
|
||
model: TrayIconKind,
|
||
session_pct: Option<f64>,
|
||
session_text: String,
|
||
weekly_pct: Option<f64>,
|
||
weekly_text: String,
|
||
is_dark: bool,
|
||
pulse_phase: u32,
|
||
}
|
||
|
||
fn render(hwnd: HWND) {
|
||
let (size_logical, dpi, inputs) = {
|
||
let bubbles = lock_bubbles();
|
||
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
|
||
return;
|
||
};
|
||
(
|
||
b.size_logical,
|
||
b.dpi,
|
||
PaintInputs {
|
||
model: b.model,
|
||
session_pct: b.session_pct,
|
||
session_text: b.session_text.clone(),
|
||
weekly_pct: b.weekly_pct,
|
||
weekly_text: b.weekly_text.clone(),
|
||
is_dark: b.is_dark,
|
||
pulse_phase: b.pulse_phase,
|
||
},
|
||
)
|
||
};
|
||
|
||
unsafe {
|
||
let screen_dc = GetDC(hwnd);
|
||
let mem_dc = CreateCompatibleDC(screen_dc);
|
||
let layout = compute_layout(size_logical, dpi, mem_dc);
|
||
|
||
let bmi = BITMAPINFO {
|
||
bmiHeader: BITMAPINFOHEADER {
|
||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||
biWidth: layout.canvas_w,
|
||
biHeight: -layout.canvas_h,
|
||
biPlanes: 1,
|
||
biBitCount: 32,
|
||
biCompression: 0,
|
||
..Default::default()
|
||
},
|
||
..Default::default()
|
||
};
|
||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||
.unwrap_or_default();
|
||
if dib.is_invalid() || bits.is_null() {
|
||
let _ = DeleteDC(mem_dc);
|
||
ReleaseDC(hwnd, screen_dc);
|
||
return;
|
||
}
|
||
let old_bmp = SelectObject(mem_dc, dib);
|
||
|
||
let pixel_count = (layout.canvas_w * layout.canvas_h) as usize;
|
||
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
||
|
||
// Everything outside the rounded rect stays 0 (fully transparent).
|
||
pixels.fill(0);
|
||
|
||
paint_background(pixels, &layout, &inputs);
|
||
paint_accent_stripe(pixels, &layout, inputs.model, inputs.is_dark);
|
||
paint_bars(pixels, &layout, &inputs);
|
||
paint_text_layer(mem_dc, &layout, &inputs);
|
||
|
||
// 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, &layout);
|
||
|
||
let mut wr = RECT::default();
|
||
let _ = GetWindowRect(hwnd, &mut wr);
|
||
let pt_dst = POINT {
|
||
x: wr.left,
|
||
y: wr.top,
|
||
};
|
||
let pt_src = POINT { x: 0, y: 0 };
|
||
let sz = SIZE {
|
||
cx: layout.canvas_w,
|
||
cy: layout.canvas_h,
|
||
};
|
||
let blend = BLENDFUNCTION {
|
||
BlendOp: 0,
|
||
BlendFlags: 0,
|
||
SourceConstantAlpha: 255,
|
||
AlphaFormat: 1, // AC_SRC_ALPHA
|
||
};
|
||
let _ = UpdateLayeredWindow(
|
||
hwnd,
|
||
screen_dc,
|
||
Some(&pt_dst),
|
||
Some(&sz),
|
||
mem_dc,
|
||
Some(&pt_src),
|
||
COLORREF(0),
|
||
Some(&blend),
|
||
ULW_ALPHA,
|
||
);
|
||
|
||
SelectObject(mem_dc, old_bmp);
|
||
let _ = DeleteObject(dib);
|
||
let _ = DeleteDC(mem_dc);
|
||
ReleaseDC(hwnd, screen_dc);
|
||
}
|
||
}
|
||
|
||
fn paint_background(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) {
|
||
let bg = if inputs.is_dark {
|
||
Color::from_hex("#1F1F1F")
|
||
} else {
|
||
Color::from_hex("#F3F3F3")
|
||
};
|
||
let bg_packed = rgb_to_dib(bg);
|
||
let blush_packed = rgb_to_dib(blend(bg, Color::from_hex("#C45020"), 0.15));
|
||
|
||
let row1_blush = inputs.session_pct.is_some_and(|p| p >= 95.0);
|
||
let row2_blush = inputs.weekly_pct.is_some_and(|p| p >= 95.0);
|
||
let row1_band = row_band(layout, layout.row1_y);
|
||
let row2_band = row_band(layout, layout.row2_y);
|
||
|
||
for y in 0..layout.canvas_h {
|
||
for x in 0..layout.canvas_w {
|
||
if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
||
continue;
|
||
}
|
||
let in_row1 = row1_blush && y >= row1_band.0 && y < row1_band.1;
|
||
let in_row2 = row2_blush && y >= row2_band.0 && y < row2_band.1;
|
||
let pixel = if in_row1 || in_row2 { blush_packed } else { bg_packed };
|
||
pixels[(y * layout.canvas_w + x) as usize] = pixel;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Top/bottom y-extent for a row's blush band — slightly taller than the bar
|
||
/// so the tint frames the row rather than just sitting under the fill.
|
||
fn row_band(layout: &BarLayout, row_top: i32) -> (i32, i32) {
|
||
let padding = (layout.bar_h / 4).max(2);
|
||
let top = (row_top - padding).max(0);
|
||
let bot = (row_top + layout.bar_h + padding).min(layout.canvas_h);
|
||
(top, bot)
|
||
}
|
||
|
||
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));
|
||
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) {
|
||
continue;
|
||
}
|
||
pixels[(y * layout.canvas_w + x) as usize] = stripe;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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")
|
||
} else {
|
||
Color::from_hex("#D6D6D6")
|
||
};
|
||
paint_one_bar(pixels, layout, layout.row1_y, inputs.session_pct, track, inputs);
|
||
paint_one_bar(pixels, layout, layout.row2_y, inputs.weekly_pct, track, inputs);
|
||
}
|
||
|
||
fn paint_one_bar(
|
||
pixels: &mut [u32],
|
||
layout: &BarLayout,
|
||
top: i32,
|
||
pct: Option<f64>,
|
||
track: Color,
|
||
inputs: &PaintInputs,
|
||
) {
|
||
let bar_w = layout.bar_right - layout.bar_left;
|
||
if bar_w <= 0 {
|
||
return;
|
||
}
|
||
let track_packed = rgb_to_dib(track);
|
||
for y in top..top + layout.bar_h {
|
||
for x in layout.bar_left..layout.bar_right {
|
||
pixels[(y * layout.canvas_w + x) as usize] = track_packed;
|
||
}
|
||
}
|
||
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 mut accent_rgb = 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);
|
||
accent_rgb = brighten(accent_rgb, t);
|
||
}
|
||
let accent_packed = rgb_to_dib(accent_rgb);
|
||
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 * layout.canvas_w + x) as usize] = accent_packed;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Triangle wave in [0, 1] with period 24 ticks. 0 at phase=0,12; 1 at phase=6,18.
|
||
fn pulse_triangle(phase: u32) -> f64 {
|
||
let p = (phase % 24) as i32;
|
||
let dist = (p - 12).abs(); // 0..12
|
||
1.0 - (dist as f64 / 12.0)
|
||
}
|
||
|
||
/// Linearly brighten `c` toward white by `t` in [0, 1].
|
||
fn brighten(c: Color, t: f64) -> Color {
|
||
// Map t to a smaller brightness delta — the pulse should be a subtle nudge.
|
||
let t = t.clamp(0.0, 1.0) * 0.30;
|
||
Color::new(
|
||
((c.r as f64) + (255.0 - c.r as f64) * t).round() as u8,
|
||
((c.g as f64) + (255.0 - c.g as f64) * t).round() as u8,
|
||
((c.b as f64) + (255.0 - c.b as f64) * t).round() as u8,
|
||
)
|
||
}
|
||
|
||
fn blend(a: Color, b: Color, t: f64) -> Color {
|
||
let t = t.clamp(0.0, 1.0);
|
||
Color::new(
|
||
((a.r as f64) * (1.0 - t) + (b.r as f64) * t).round() as u8,
|
||
((a.g as f64) * (1.0 - t) + (b.g as f64) * t).round() as u8,
|
||
((a.b as f64) * (1.0 - t) + (b.b as f64) * t).round() as u8,
|
||
)
|
||
}
|
||
|
||
/// One pass over the GDI text: row labels (muted) + percent + countdown.
|
||
/// The percent column lives between the bar and the countdown so the
|
||
/// numeric readouts cluster together for quick scanning, and the percent
|
||
/// text always sits on the bubble background — never on the bar fill.
|
||
fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
|
||
let text_color = if inputs.is_dark {
|
||
Color::from_hex("#EAEAEA")
|
||
} else {
|
||
Color::from_hex("#1F1F1F")
|
||
};
|
||
let muted_color = if inputs.is_dark {
|
||
Color::from_hex("#888888")
|
||
} else {
|
||
Color::from_hex("#6E6E6E")
|
||
};
|
||
|
||
let font_name = wide_str("Segoe UI");
|
||
unsafe {
|
||
let main_font = create_font(layout.font_px, &font_name, FW_NORMAL.0 as i32);
|
||
let bold_font = create_font(layout.font_px, &font_name, FW_SEMIBOLD.0 as i32);
|
||
let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32);
|
||
SetBkMode(hdc, TRANSPARENT);
|
||
|
||
// Save the DC's original font so we can restore it before deleting
|
||
// ours. DeleteObject silently fails on a still-selected HFONT,
|
||
// which would leak the handle on every paint frame.
|
||
let prev_font = SelectObject(hdc, label_font);
|
||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||
draw_label(hdc, layout, layout.row1_y, "5h");
|
||
draw_label(hdc, layout, layout.row2_y, "7d");
|
||
|
||
// Percent in its own column (between bar and countdown).
|
||
SelectObject(hdc, bold_font);
|
||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||
draw_percent(hdc, layout, layout.row1_y, inputs.session_pct);
|
||
draw_percent(hdc, layout, layout.row2_y, inputs.weekly_pct);
|
||
|
||
// Countdown on the right.
|
||
SelectObject(hdc, main_font);
|
||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||
draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text);
|
||
draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text);
|
||
|
||
// Restore the original font, then it is safe to delete ours.
|
||
SelectObject(hdc, prev_font);
|
||
let _ = DeleteObject(main_font);
|
||
let _ = DeleteObject(bold_font);
|
||
let _ = DeleteObject(label_font);
|
||
}
|
||
}
|
||
|
||
fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT {
|
||
unsafe {
|
||
CreateFontW(
|
||
-height_px,
|
||
0,
|
||
0,
|
||
0,
|
||
weight,
|
||
0,
|
||
0,
|
||
0,
|
||
DEFAULT_CHARSET.0 as u32,
|
||
OUT_DEFAULT_PRECIS.0 as u32,
|
||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||
CLEARTYPE_QUALITY.0 as u32,
|
||
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
|
||
PCWSTR::from_raw(name_w.as_ptr()),
|
||
)
|
||
}
|
||
}
|
||
|
||
fn draw_label(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) {
|
||
let mut text_w = wide_str(text);
|
||
let len_no_nul = text_w.len().saturating_sub(1);
|
||
let mut rect = RECT {
|
||
left: layout.label_left,
|
||
top: row_top - 2,
|
||
right: layout.label_right,
|
||
bottom: row_top + layout.bar_h + 2,
|
||
};
|
||
unsafe {
|
||
let _ = DrawTextW(
|
||
hdc,
|
||
&mut text_w[..len_no_nul],
|
||
&mut rect,
|
||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn draw_percent(hdc: HDC, layout: &BarLayout, row_top: i32, pct: Option<f64>) {
|
||
let Some(p) = pct else {
|
||
return;
|
||
};
|
||
let text = format!("{:.0}%", p);
|
||
let mut text_buf = wide_str(&text);
|
||
let len_no_nul = text_buf.len().saturating_sub(1);
|
||
let mut rect = RECT {
|
||
left: layout.percent_left,
|
||
top: row_top,
|
||
right: layout.percent_right,
|
||
bottom: row_top + layout.bar_h,
|
||
};
|
||
unsafe {
|
||
let _ = DrawTextW(
|
||
hdc,
|
||
&mut text_buf[..len_no_nul],
|
||
&mut rect,
|
||
DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn draw_countdown(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) {
|
||
if text.is_empty() {
|
||
return;
|
||
}
|
||
// Left-align so the countdown sits right next to the bar with only the
|
||
// `label_pad` gap. Right-aligning to the bubble's far edge left a visible
|
||
// float between bar end and number.
|
||
let mut text_w = wide_str(text);
|
||
let len_no_nul = text_w.len().saturating_sub(1);
|
||
let mut rect = RECT {
|
||
left: layout.right_text_left,
|
||
top: row_top - 2,
|
||
right: layout.right_text_right,
|
||
bottom: row_top + layout.bar_h + 2,
|
||
};
|
||
unsafe {
|
||
let _ = DrawTextW(
|
||
hdc,
|
||
&mut text_w[..len_no_nul],
|
||
&mut rect,
|
||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP | DT_END_ELLIPSIS,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn apply_alpha_mask(pixels: &mut [u32], layout: &BarLayout) {
|
||
for y in 0..layout.canvas_h {
|
||
for x in 0..layout.canvas_w {
|
||
let idx = (y * layout.canvas_w + x) as usize;
|
||
if point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
||
pixels[idx] |= 0xFF00_0000;
|
||
} else {
|
||
pixels[idx] = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
fn luminance(c: Color) -> u32 {
|
||
(c.r as u32 * 299 + c.g as u32 * 587 + c.b as u32 * 114) / 1000
|
||
}
|
||
|
||
/// Returns true when `c` is light enough that black text is more readable
|
||
/// than white text on top of it.
|
||
fn use_dark_text_over(c: Color) -> bool {
|
||
luminance(c) >= 150
|
||
}
|
||
|
||
// ---------- Helpers ----------
|
||
|
||
fn primary_dpi() -> u32 {
|
||
unsafe { GetDpiForSystem().max(96) }
|
||
}
|
||
|
||
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
|
||
((logical as i64) * (dpi as i64) / 96) as 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 {
|
||
let monitor = MonitorFromPoint(POINT { x: 0, y: 0 }, MONITOR_DEFAULTTOPRIMARY);
|
||
let mut info = MONITORINFO {
|
||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||
..Default::default()
|
||
};
|
||
let wa = if GetMonitorInfoW(monitor, &mut info).as_bool() {
|
||
info.rcWork
|
||
} else {
|
||
RECT {
|
||
left: 0,
|
||
top: 0,
|
||
right: 1920,
|
||
bottom: 1080,
|
||
}
|
||
};
|
||
let gap = 24;
|
||
let stagger = match model {
|
||
ProviderId::Claude => 0,
|
||
ProviderId::ChatGpt => height_px + gap,
|
||
};
|
||
let x = wa.right - width_px - gap;
|
||
let y = wa.bottom - height_px - gap - stagger;
|
||
(x, y)
|
||
}
|
||
}
|