Compare commits

..
6 Commits
Author SHA1 Message Date
tiennm99 5e00009e5a chore: bump version to 0.3.8 2026-06-01 14:37:26 +07:00
tiennm99 c91dc996f8 fix: tighten fullscreen auto-hide detection 2026-06-01 14:35:26 +07:00
tiennm99 b2e48cc119 chore: bump version to 0.3.7 2026-05-25 11:37:34 +07:00
tiennm99 bee9f3cfa0 fix(ui): use theme-aware monochrome accent for Codex 2026-05-25 11:37:29 +07:00
tiennm99 860eb62d1b chore: bump version to 0.3.6 2026-05-25 11:15:13 +07:00
tiennm99 c424b17bd7 fix(ui): reverse remaining-time direction to read like a clock
Inner ring's consumed wedge now grows clockwise from 12 o'clock (mirroring
a clock-hand countdown) and the tail remaining-time bar shrinks toward the
right edge instead of the left. Usage ring and weekly usage bar unchanged.
2026-05-25 11:15:07 +07:00
4 changed files with 310 additions and 50 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.3.5"
version = "0.3.8"
dependencies = [
"dirs",
"embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.3.5"
version = "0.3.8"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
+293 -40
View File
@@ -19,6 +19,9 @@ use std::time::{Duration, SystemTime};
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Dwm::{
DwmGetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS,
};
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
use windows::Win32::UI::HiDpi::*;
@@ -55,6 +58,7 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
const FULLSCREEN_POLL_MS: u32 = 1500;
const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
@@ -965,30 +969,7 @@ fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) {
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 evaluation = evaluate_foreground_fullscreen(fg, bubble_hwnd);
let (was_hidden_by_fs, user_hidden) = {
let bubbles = lock_bubbles();
@@ -998,26 +979,265 @@ fn check_fullscreen(bubble_hwnd: HWND) {
(b.hidden_by_fullscreen, b.user_hidden)
};
if is_fullscreen && !was_hidden_by_fs {
if evaluation.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);
log_fullscreen_decision("hide", &evaluation, false);
} else if !evaluation.is_fullscreen && was_hidden_by_fs {
show_after_fullscreen(bubble_hwnd, user_hidden);
log_fullscreen_decision("show", &evaluation, user_hidden);
}
}
struct FullscreenEvaluation {
is_fullscreen: bool,
hwnd: HWND,
class_name: String,
bounds: Option<RECT>,
bounds_source: &'static str,
monitor: Option<RECT>,
reason: &'static str,
}
fn evaluate_foreground_fullscreen(fg: HWND, bubble_hwnd: HWND) -> FullscreenEvaluation {
let mut evaluation = FullscreenEvaluation {
is_fullscreen: false,
hwnd: fg,
class_name: String::new(),
bounds: None,
bounds_source: "none",
monitor: None,
reason: "not evaluated",
};
if fg == HWND::default() {
evaluation.reason = "no foreground window";
return evaluation;
}
evaluation.class_name = window_class_name(fg);
if fg == bubble_hwnd || is_ignored_fullscreen_class(&evaluation.class_name) {
evaluation.reason = "ignored foreground class";
return evaluation;
}
if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } {
evaluation.reason = "foreground invisible or minimized";
return evaluation;
}
if is_dwm_cloaked(fg) {
evaluation.reason = "foreground cloaked";
return evaluation;
}
let Some((bounds, source)) = visible_window_bounds(fg) else {
evaluation.reason = "foreground bounds unavailable";
return evaluation;
};
evaluation.bounds = Some(bounds);
evaluation.bounds_source = source;
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
if monitor.is_invalid() {
evaluation.reason = "foreground monitor unavailable";
return evaluation;
}
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } {
evaluation.reason = "foreground monitor info unavailable";
return evaluation;
}
evaluation.monitor = Some(info.rcMonitor);
if !rect_covers_monitor(&bounds, &info.rcMonitor) {
evaluation.reason = "visible bounds do not cover monitor";
return evaluation;
}
if !window_style_allows_fullscreen(fg) {
evaluation.reason = "standard framed window";
return evaluation;
}
evaluation.is_fullscreen = true;
evaluation.reason = "visible bounds cover monitor";
evaluation
}
fn show_after_fullscreen(bubble_hwnd: HWND, user_hidden: bool) {
if !user_hidden {
unsafe {
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
}
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
b.hidden_by_fullscreen = false;
// 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;
}
}
fn visible_window_bounds(hwnd: HWND) -> Option<(RECT, &'static str)> {
let mut rect = RECT::default();
let dwm_ok = unsafe {
DwmGetWindowAttribute(
hwnd,
DWMWA_EXTENDED_FRAME_BOUNDS,
&mut rect as *mut _ as *mut c_void,
std::mem::size_of::<RECT>() as u32,
)
.is_ok()
};
if dwm_ok && !rect_is_empty(&rect) {
return Some((rect, "dwm-extended-frame"));
}
unsafe {
if GetWindowRect(hwnd, &mut rect).is_err() {
return None;
}
}
if rect_is_empty(&rect) {
None
} else {
Some((rect, "window-rect"))
}
}
fn is_dwm_cloaked(hwnd: HWND) -> bool {
let mut cloaked = 0u32;
unsafe {
DwmGetWindowAttribute(
hwnd,
DWMWA_CLOAKED,
&mut cloaked as *mut _ as *mut c_void,
std::mem::size_of::<u32>() as u32,
)
.is_ok()
&& cloaked != 0
}
}
fn window_class_name(hwnd: HWND) -> String {
let mut buf = [0u16; 256];
let len = unsafe { GetClassNameW(hwnd, &mut buf) };
if len <= 0 {
String::new()
} else {
String::from_utf16_lossy(&buf[..len as usize])
}
}
fn is_ignored_fullscreen_class(class_name: &str) -> bool {
["Progman", "WorkerW", "Shell_TrayWnd", CLASS_NAME]
.iter()
.any(|ignored| class_name.eq_ignore_ascii_case(ignored))
}
fn window_style_allows_fullscreen(hwnd: HWND) -> bool {
unsafe {
SetLastError(WIN32_ERROR(0));
}
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) };
if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) {
return false;
}
window_style_bits_allow_fullscreen(style as u32)
}
fn window_style_bits_allow_fullscreen(style: u32) -> bool {
let has_child = style & WS_CHILD.0 != 0;
let has_popup = style & WS_POPUP.0 != 0;
let has_caption = style & WS_CAPTION.0 != 0;
let has_resize_frame = style & WS_THICKFRAME.0 != 0;
!has_child && (has_popup || (!has_caption && !has_resize_frame))
}
fn rect_covers_monitor(rect: &RECT, bounds: &RECT) -> bool {
rect.left <= bounds.left + FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.top <= bounds.top + FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.right >= bounds.right - FULLSCREEN_EDGE_TOLERANCE_PX
&& rect.bottom >= bounds.bottom - FULLSCREEN_EDGE_TOLERANCE_PX
}
fn rect_is_empty(rect: &RECT) -> bool {
rect.right <= rect.left || rect.bottom <= rect.top
}
fn log_fullscreen_decision(action: &str, evaluation: &FullscreenEvaluation, user_hidden: bool) {
log::info!(
"bubble fullscreen {action} fg=0x{:X} class={} reason={} bounds_source={} bounds={} monitor={} user_hidden={}",
evaluation.hwnd.0 as usize,
if evaluation.class_name.is_empty() {
"<unknown>"
} else {
evaluation.class_name.as_str()
},
evaluation.reason,
evaluation.bounds_source,
format_rect(evaluation.bounds.as_ref()),
format_rect(evaluation.monitor.as_ref()),
user_hidden
);
}
fn format_rect(rect: Option<&RECT>) -> String {
match rect {
Some(r) => format!(
"({},{} {}x{})",
r.left,
r.top,
r.right - r.left,
r.bottom - r.top
),
None => String::from("<none>"),
}
}
#[cfg(test)]
mod fullscreen_tests {
use super::*;
#[test]
fn style_bits_allow_popup_or_borderless_windows_only() {
assert!(window_style_bits_allow_fullscreen(WS_POPUP.0));
assert!(window_style_bits_allow_fullscreen(0));
assert!(!window_style_bits_allow_fullscreen(WS_CAPTION.0 | WS_THICKFRAME.0));
assert!(!window_style_bits_allow_fullscreen(WS_CHILD.0 | WS_POPUP.0));
}
#[test]
fn rect_cover_check_allows_small_edge_differences() {
let monitor = RECT {
left: 0,
top: 0,
right: 1920,
bottom: 1080,
};
let almost_exact = RECT {
left: 1,
top: 2,
right: 1919,
bottom: 1078,
};
let inset = RECT {
left: 8,
top: 0,
right: 1920,
bottom: 1080,
};
assert!(rect_covers_monitor(&almost_exact, &monitor));
assert!(!rect_covers_monitor(&inset, &monitor));
}
}
// ---------- Painting ----------
@@ -1288,9 +1508,12 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
let mut stroke = Stroke::default();
stroke.width = layout.time_ring_stroke_w;
stroke.line_cap = LineCap::Round;
if let Some(path) =
build_arc(layout.ring_cx, layout.ring_cy, layout.time_ring_radius, frac)
{
if let Some(path) = build_remaining_arc(
layout.ring_cx,
layout.ring_cy,
layout.time_ring_radius,
frac,
) {
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
@@ -1338,9 +1561,11 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
inputs.weekly_resets_at,
window_duration_secs(inputs.model, UsageWindowKind::Secondary),
) {
let fill_w = bar_w * frac;
let fill_w = (bar_w * frac).min(bar_w);
if fill_w > 0.0 {
paint_pill(&mut pixmap, bar_x, bar_y, fill_w.min(bar_w), bar_h, cap, time_fill);
// Anchor on the right edge so the bar shrinks toward the right as time passes.
let fill_x = bar_x + bar_w - fill_w;
paint_pill(&mut pixmap, fill_x, bar_y, fill_w, bar_h, cap, time_fill);
}
}
}
@@ -1391,6 +1616,34 @@ fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_
pb.finish()
}
/// Clockwise arc that ENDS at 12 o'clock; the consumed wedge grows clockwise
/// from 12 as `remaining_fraction` shrinks, mirroring a clock-hand countdown.
fn build_remaining_arc(
cx: f32,
cy: f32,
radius: f32,
remaining_fraction: f32,
) -> Option<tiny_skia::Path> {
let frac = remaining_fraction.clamp(0.0, 1.0);
let segments = ((frac * 64.0).ceil() as usize).max(1);
let mut pb = PathBuilder::new();
let twelve: f32 = -std::f32::consts::FRAC_PI_2;
let total = frac * std::f32::consts::TAU;
let start_angle = twelve + (std::f32::consts::TAU - total);
for i in 0..=segments {
let t = i as f32 / segments as f32;
let a = start_angle + t * total;
let x = cx + a.cos() * radius;
let y = cy + a.sin() * radius;
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.finish()
}
/// Copy a premultiplied-RGBA `Pixmap` into the 32bpp BI_RGB DIB the bubble
/// uses for `UpdateLayeredWindow`. The DIB stores BGRA bytes (little-endian
/// `0xAARRGGBB` when read as u32); tiny-skia's premultiplied alpha is exactly
+15 -8
View File
@@ -5,20 +5,27 @@
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 {
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
/// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
/// near-black `#1A1A1A` on light — so the accent stays readable against both
/// the dark bubble surface and the `#F3F3F3` light background.
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"),
ProviderId::ChatGpt => {
if is_dark {
Color::from_hex("#E5E5E5")
} else {
Color::from_hex("#1A1A1A")
}
}
}
}
/// 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.
/// color so Codex bars render monochrome (light-on-dark / dark-on-light) while
/// Claude bars stay orange; the warning bands are theme-aware so light-mode
/// amber stays readable against the `#F3F3F3` background.
///
/// - <60% → provider accent
/// - 6080% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)