mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-08-18 08:24:41 +00:00
refactor: fix latent bugs, invert bubble→app deps, unify color path
Whole-project review pass over the entire crate. No new features. All function definitions preserved; layout and visibility reorganised. Bug fixes: - bubble: ExtractIconExW HICON pair was leaked per bubble toggle. Extract once into a process-wide OnceLock, reuse forever (bounded). - usage/anthropic: parse_iso8601 was stripping the UTC offset without applying it — negative-offset users saw countdowns up to 14h wrong. Now parses signed minutes and computes utc_secs = local - off*60. Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so malformed API responses can't index DAYS_IN_MONTH out of bounds. - usage: clamp utilization to [0,100] at all four Window construction sites so a misbehaving server can't render "121%". - bubble: GetDC and CreateCompatibleDC results weren't checked. Guard both; release the screen DC on the CreateCompatibleDC failure path. Refactor: - Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId directly everywhere. Inline the identity-function kind_to_provider. - Delete panel::bar_color_for shim (was just argument-reorder glue). - Replace local scale_to_dpi fns in bubble.rs and panel.rs with use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the live import graph; was unused before). - Delete dead PCWSTR import + #[allow(dead_code)] sentinel in tray/badge.rs; fold the trailing `use BOOL` into the top imports. - Inline app::primary_dpi() to crate::os::dpi::for_system(). app.rs: - Add update_settings(|s: &mut AppState|) helper that locks state, runs the closure, snapshots Settings, drops the lock, then saves to disk. Convert four pure-mutate-then-save callsites. Layering: bubble.rs no longer reaches upward into crate::app::. Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and bubble::install_callbacks(). The wnd_proc dispatches the six prior upward calls via a private dispatch() helper. app::run installs callbacks once at startup; the six on_bubble_* / recheck_theme fns are demoted from pub fn to fn. Resource-warning logs added: dispatch() warns on uninstalled callbacks; app_icons() warns when ExtractIconExW returns nulls. Build: cargo build --release clean; cargo clippy reports zero new warnings (11 pre-existing, all in untouched code).
This commit is contained in:
+47
-63
@@ -27,9 +27,7 @@ use crate::net;
|
||||
use crate::os;
|
||||
use crate::panel::{self, PanelData};
|
||||
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
||||
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData};
|
||||
use crate::usage::ProviderId as TrayIconKind;
|
||||
use crate::tray::WM_APP_TRAY;
|
||||
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
|
||||
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
||||
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
||||
|
||||
@@ -110,7 +108,7 @@ enum UpdateStatus {
|
||||
|
||||
struct AppState {
|
||||
msg_hwnd: SendHwnd,
|
||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
||||
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||
settings: Settings,
|
||||
i18n: I18n,
|
||||
is_dark: bool,
|
||||
@@ -145,6 +143,21 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
||||
state().lock().expect("app state mutex poisoned")
|
||||
}
|
||||
|
||||
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
|
||||
/// persist it to disk. The lock is released before the disk write so the UI
|
||||
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
|
||||
fn update_settings(f: impl FnOnce(&mut AppState)) {
|
||||
let snap = {
|
||||
let mut guard = lock_state();
|
||||
let Some(s) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
f(s);
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
}
|
||||
|
||||
// ---------- Entry ----------
|
||||
|
||||
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
||||
@@ -230,6 +243,15 @@ pub fn run(args: crate::AppArgs) {
|
||||
last_balloon_at: None,
|
||||
});
|
||||
|
||||
bubble::install_callbacks(bubble::Callbacks {
|
||||
on_click: on_bubble_click,
|
||||
on_right_click: on_bubble_right_click,
|
||||
on_moved: on_bubble_moved,
|
||||
on_resized: on_bubble_resized,
|
||||
on_menu_command,
|
||||
on_settings_changed: recheck_theme,
|
||||
});
|
||||
|
||||
create_initial_bubbles();
|
||||
refresh_tray_icons();
|
||||
|
||||
@@ -307,7 +329,7 @@ fn create_initial_bubbles() {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
|
||||
fn spawn_bubble(kind: ProviderId, 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.
|
||||
@@ -365,40 +387,24 @@ unsafe extern "system" fn msg_wnd_proc(
|
||||
|
||||
// ---------- Bubble callbacks ----------
|
||||
|
||||
pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) {
|
||||
fn on_bubble_click(hwnd: HWND, model: ProviderId) {
|
||||
let data = build_panel_data(model);
|
||||
panel::toggle(data, hwnd);
|
||||
}
|
||||
|
||||
pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) {
|
||||
fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) {
|
||||
show_context_menu(hwnd);
|
||||
}
|
||||
|
||||
pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.settings.bubble_positions.set(model, pos);
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
||||
update_settings(|s| s.settings.bubble_positions.set(model, pos));
|
||||
}
|
||||
|
||||
pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.settings.bubble_size_logical = size_logical;
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
|
||||
update_settings(|s| s.settings.bubble_size_logical = size_logical);
|
||||
}
|
||||
|
||||
pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
let id = (id & 0xFFFF) as u16;
|
||||
match id {
|
||||
IDM_REFRESH => spawn_poll_thread(),
|
||||
@@ -618,8 +624,7 @@ fn propagate_to_ui() {
|
||||
};
|
||||
|
||||
for (kind, hwnd) in snap.bubbles.iter() {
|
||||
let id = kind_to_provider(*kind);
|
||||
let entry = snap.snapshots.get(&id);
|
||||
let entry = snap.snapshots.get(kind);
|
||||
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
||||
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
||||
// The bubble paints the percent inline inside the bar fill, so it
|
||||
@@ -643,8 +648,7 @@ fn propagate_to_ui() {
|
||||
|
||||
if panel::is_visible() {
|
||||
if let Some(model) = panel::current_model() {
|
||||
let id = kind_to_provider(model);
|
||||
if let Some(provider_state) = snap.snapshots.get(&id) {
|
||||
if let Some(provider_state) = snap.snapshots.get(&model) {
|
||||
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
||||
}
|
||||
}
|
||||
@@ -654,7 +658,7 @@ fn propagate_to_ui() {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct UiSnapshot {
|
||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
||||
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||
snapshots: HashMap<ProviderId, ProviderUiState>,
|
||||
settings: Settings,
|
||||
i18n_strings: LocaleStrings,
|
||||
@@ -663,21 +667,13 @@ struct UiSnapshot {
|
||||
last_poll_ok: bool,
|
||||
}
|
||||
|
||||
fn kind_to_provider(k: TrayIconKind) -> ProviderId {
|
||||
match k {
|
||||
ProviderId::Claude => ProviderId::Claude,
|
||||
ProviderId::ChatGpt => ProviderId::ChatGpt,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_panel_data(model: TrayIconKind) -> PanelData {
|
||||
fn build_panel_data(model: ProviderId) -> PanelData {
|
||||
let s = lock_state();
|
||||
let Some(s) = s.as_ref() else {
|
||||
return placeholder_panel(model);
|
||||
};
|
||||
let id = kind_to_provider(model);
|
||||
let strings = s.i18n.strings().clone();
|
||||
let provider_state = s.snapshots.get(&id).cloned().unwrap_or_default();
|
||||
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
|
||||
PanelData {
|
||||
model,
|
||||
session_pct: provider_state.windows.primary.utilization,
|
||||
@@ -689,7 +685,7 @@ fn build_panel_data(model: TrayIconKind) -> PanelData {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiState) -> PanelData {
|
||||
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
|
||||
PanelData {
|
||||
model,
|
||||
session_pct: p.windows.primary.utilization,
|
||||
@@ -701,7 +697,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiS
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_panel(model: TrayIconKind) -> PanelData {
|
||||
fn placeholder_panel(model: ProviderId) -> PanelData {
|
||||
let strings = i18n::I18n::load(None).strings().clone();
|
||||
PanelData {
|
||||
model,
|
||||
@@ -816,7 +812,7 @@ fn handle_tray_action(action: TrayAction) {
|
||||
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
||||
/// posts that to every top-level window when the user toggles light/dark
|
||||
/// in Settings, so this naturally fires once per change.
|
||||
pub fn recheck_theme() {
|
||||
fn recheck_theme() {
|
||||
let now_dark = os::theme::is_dark();
|
||||
let changed = {
|
||||
let mut s = lock_state();
|
||||
@@ -1161,7 +1157,7 @@ fn set_poll_interval(ms: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_model(model: TrayIconKind) {
|
||||
fn toggle_model(model: ProviderId) {
|
||||
let (settings, is_dark) = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
@@ -1249,33 +1245,21 @@ fn reset_positions() {
|
||||
}
|
||||
|
||||
fn set_language(_dummy: Option<()>) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
update_settings(|s| {
|
||||
s.i18n.set_active(None);
|
||||
s.settings.language = None;
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
});
|
||||
propagate_to_ui();
|
||||
}
|
||||
|
||||
fn set_language_by_index(idx: usize) {
|
||||
let snap = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
update_settings(|s| {
|
||||
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
||||
if let Some(c) = code.as_deref() {
|
||||
s.i18n.set_active(Some(c));
|
||||
}
|
||||
s.settings.language = code;
|
||||
s.settings.clone()
|
||||
};
|
||||
settings::save(&snap);
|
||||
});
|
||||
propagate_to_ui();
|
||||
}
|
||||
|
||||
|
||||
+81
-35
@@ -26,13 +26,13 @@ use windows::Win32::UI::Shell::{
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::os::dpi::scale as scale_to_dpi;
|
||||
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 ----------
|
||||
|
||||
@@ -88,7 +88,7 @@ fn aspect_at_width(w_logical: i32) -> (i32, i32) {
|
||||
}
|
||||
|
||||
pub struct BubbleConfig {
|
||||
pub model: TrayIconKind,
|
||||
pub model: ProviderId,
|
||||
pub size_logical: i32,
|
||||
pub position: Option<(i32, i32)>,
|
||||
pub session_pct: Option<f64>,
|
||||
@@ -103,6 +103,34 @@ fn bubble_height_logical(width_logical: i32) -> i32 {
|
||||
((width_logical * den) / num).max(20)
|
||||
}
|
||||
|
||||
/// Owner-supplied event callbacks. The bubble window proc is a leaf — it
|
||||
/// doesn't know about `app`. The owner installs these once at startup so the
|
||||
/// proc can dispatch UI events back without an upward `crate::app::` reach.
|
||||
pub struct Callbacks {
|
||||
pub on_click: fn(HWND, ProviderId),
|
||||
pub on_right_click: fn(HWND, ProviderId, POINT),
|
||||
pub on_moved: fn(ProviderId, (i32, i32)),
|
||||
pub on_resized: fn(ProviderId, i32),
|
||||
pub on_menu_command: fn(u32, HWND),
|
||||
pub on_settings_changed: fn(),
|
||||
}
|
||||
|
||||
static CALLBACKS: OnceLock<Callbacks> = OnceLock::new();
|
||||
|
||||
/// Install the owner's callbacks. Called once by `app::run` before any
|
||||
/// bubble is created. Subsequent calls are silently ignored.
|
||||
pub fn install_callbacks(cb: Callbacks) {
|
||||
let _ = CALLBACKS.set(cb);
|
||||
}
|
||||
|
||||
fn dispatch<F: FnOnce(&Callbacks)>(f: F) {
|
||||
if let Some(cb) = CALLBACKS.get() {
|
||||
f(cb);
|
||||
} else {
|
||||
log::warn!("bubble event dispatched before install_callbacks; event dropped");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the bubble window class. Idempotent; safe to call before the first
|
||||
/// `create()` from the UI thread.
|
||||
pub fn register_class() {
|
||||
@@ -133,7 +161,7 @@ pub fn create(config: BubbleConfig) -> HWND {
|
||||
let initial_size_logical = config
|
||||
.size_logical
|
||||
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
let dpi_for_create = primary_dpi();
|
||||
let dpi_for_create = crate::os::dpi::for_system();
|
||||
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
|
||||
@@ -166,19 +194,11 @@ pub fn create(config: BubbleConfig) -> HWND {
|
||||
}
|
||||
|
||||
// Embed app icon in window non-client (mostly cosmetic; toolwindows
|
||||
// don't show captions but the icon helps in dev tooling).
|
||||
// don't show captions but the icon helps in dev tooling). The HICONs
|
||||
// are extracted once at process startup and reused across every bubble
|
||||
// create() so we don't leak a pair per toggle cycle.
|
||||
let (large_icon, small_icon) = app_icons();
|
||||
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,
|
||||
@@ -245,6 +265,33 @@ pub fn destroy(hwnd: HWND) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the EXE's own icon pair once per process. Stored as raw pointer
|
||||
/// values because `HICON` is `!Send`/`!Sync`; reconstituted for each caller.
|
||||
/// The pair is intentionally never destroyed — Windows tears them down on
|
||||
/// process exit, and one pair per process is bounded leak rather than the
|
||||
/// O(bubble-toggles) leak we'd get from extracting per `create()`.
|
||||
fn app_icons() -> (HICON, HICON) {
|
||||
static ICONS: OnceLock<(isize, isize)> = OnceLock::new();
|
||||
let (big, small) = *ICONS.get_or_init(|| unsafe {
|
||||
let mut large = HICON::default();
|
||||
let mut small = HICON::default();
|
||||
let mut exe = [0u16; 260];
|
||||
GetModuleFileNameW(HMODULE::default(), &mut exe);
|
||||
let _ = ExtractIconExW(
|
||||
PCWSTR::from_raw(exe.as_ptr()),
|
||||
0,
|
||||
Some(&mut large),
|
||||
Some(&mut small),
|
||||
1,
|
||||
);
|
||||
if large.is_invalid() && small.is_invalid() {
|
||||
log::warn!("ExtractIconExW yielded null handles; bubbles will be iconless");
|
||||
}
|
||||
(large.0 as isize, small.0 as isize)
|
||||
});
|
||||
(HICON(big as *mut _), HICON(small as *mut _))
|
||||
}
|
||||
|
||||
pub fn update_data(
|
||||
hwnd: HWND,
|
||||
session_pct: Option<f64>,
|
||||
@@ -339,7 +386,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
Some((r.left, r.top))
|
||||
}
|
||||
|
||||
pub fn model(hwnd: HWND) -> Option<TrayIconKind> {
|
||||
pub fn model(hwnd: HWND) -> Option<ProviderId> {
|
||||
lock_bubbles()
|
||||
.get(&(hwnd.0 as isize))
|
||||
.map(|b| b.model)
|
||||
@@ -354,7 +401,7 @@ pub fn size_logical(hwnd: HWND) -> Option<i32> {
|
||||
// ---------- State ----------
|
||||
|
||||
struct BubbleState {
|
||||
model: TrayIconKind,
|
||||
model: ProviderId,
|
||||
size_logical: i32,
|
||||
dpi: u32,
|
||||
session_pct: Option<f64>,
|
||||
@@ -422,18 +469,18 @@ unsafe extern "system" fn wnd_proc(
|
||||
snap_to_edge(hwnd);
|
||||
if let Some(model) = model(hwnd) {
|
||||
if let Some(pos) = position(hwnd) {
|
||||
crate::app::on_bubble_moved(model, pos);
|
||||
dispatch(|cb| (cb.on_moved)(model, pos));
|
||||
}
|
||||
}
|
||||
} else if let Some(model) = model(hwnd) {
|
||||
crate::app::on_bubble_click(hwnd, model);
|
||||
dispatch(|cb| (cb.on_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);
|
||||
dispatch(|cb| (cb.on_right_click)(hwnd, model, pt));
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
@@ -484,7 +531,7 @@ unsafe extern "system" fn wnd_proc(
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_COMMAND => {
|
||||
crate::app::on_menu_command(wparam.0 as u32, hwnd);
|
||||
dispatch(|cb| (cb.on_menu_command)(wparam.0 as u32, hwnd));
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_SETTINGCHANGE => {
|
||||
@@ -494,7 +541,7 @@ unsafe extern "system" fn wnd_proc(
|
||||
// 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();
|
||||
dispatch(|cb| (cb.on_settings_changed)());
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
@@ -591,7 +638,7 @@ fn resize_step(hwnd: HWND, delta: i32) {
|
||||
}
|
||||
render(hwnd);
|
||||
if let Some(m) = model(hwnd) {
|
||||
crate::app::on_bubble_resized(m, new_logical);
|
||||
dispatch(|cb| (cb.on_resized)(m, new_logical));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +857,7 @@ fn clamp_into_work_area(hwnd: HWND) {
|
||||
// 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));
|
||||
.is_some_and(|b| matches!(b.model, ProviderId::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);
|
||||
@@ -1060,7 +1107,7 @@ fn rgb_to_dib(c: Color) -> u32 {
|
||||
}
|
||||
|
||||
struct PaintInputs {
|
||||
model: TrayIconKind,
|
||||
model: ProviderId,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
weekly_pct: Option<f64>,
|
||||
@@ -1092,7 +1139,14 @@ fn render(hwnd: HWND) {
|
||||
|
||||
unsafe {
|
||||
let screen_dc = GetDC(hwnd);
|
||||
if screen_dc.is_invalid() {
|
||||
return;
|
||||
}
|
||||
let mem_dc = CreateCompatibleDC(screen_dc);
|
||||
if mem_dc.is_invalid() {
|
||||
ReleaseDC(hwnd, screen_dc);
|
||||
return;
|
||||
}
|
||||
let layout = compute_layout(size_logical, dpi, mem_dc);
|
||||
|
||||
let bmi = BITMAPINFO {
|
||||
@@ -1204,7 +1258,7 @@ fn row_band(layout: &BarLayout, row_top: i32) -> (i32, i32) {
|
||||
(top, bot)
|
||||
}
|
||||
|
||||
fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: TrayIconKind, is_dark: bool) {
|
||||
fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: ProviderId, is_dark: bool) {
|
||||
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 {
|
||||
@@ -1460,15 +1514,7 @@ fn use_dark_text_over(c: Color) -> bool {
|
||||
|
||||
// ---------- 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) {
|
||||
fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (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 {
|
||||
|
||||
+7
-13
@@ -16,9 +16,9 @@ use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::i18n::LocaleStrings;
|
||||
use crate::os::dpi::scale as scale_to_dpi;
|
||||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||
const PANEL_W_LOGICAL: i32 = 280;
|
||||
@@ -30,7 +30,7 @@ const RIGHT_TEXT_W_LOGICAL: i32 = 96;
|
||||
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
||||
|
||||
pub struct PanelData {
|
||||
pub model: TrayIconKind,
|
||||
pub model: ProviderId,
|
||||
pub session_pct: f64,
|
||||
pub session_text: String,
|
||||
pub weekly_pct: f64,
|
||||
@@ -86,7 +86,7 @@ pub fn is_visible() -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn current_model() -> Option<TrayIconKind> {
|
||||
pub fn current_model() -> Option<ProviderId> {
|
||||
lock_state().as_ref().map(|p| p.data.model)
|
||||
}
|
||||
|
||||
@@ -291,8 +291,10 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
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);
|
||||
let session_accent =
|
||||
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct);
|
||||
let weekly_accent =
|
||||
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct);
|
||||
|
||||
unsafe {
|
||||
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
||||
@@ -498,10 +500,6 @@ fn draw_text(
|
||||
}
|
||||
}
|
||||
|
||||
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
|
||||
crate::usage_color::bar_fill_color(model, is_dark, percent)
|
||||
}
|
||||
|
||||
fn clone_data() -> Option<PanelData> {
|
||||
let guard = lock_state();
|
||||
let p = guard.as_ref()?;
|
||||
@@ -544,7 +542,3 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
|
||||
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
|
||||
((logical as i64) * (dpi as i64) / 96) as i32
|
||||
}
|
||||
|
||||
+3
-4
@@ -6,7 +6,6 @@ use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
|
||||
|
||||
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
||||
use crate::usage::ProviderId;
|
||||
type TrayIconKind = ProviderId;
|
||||
|
||||
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
||||
// edge still passes the validator, but a position fully on a disconnected
|
||||
@@ -52,19 +51,19 @@ pub struct BubblePositions {
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
|
||||
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> {
|
||||
match model {
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
|
||||
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) {
|
||||
match model {
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, model: TrayIconKind) {
|
||||
pub fn reset(&mut self, model: ProviderId) {
|
||||
match model {
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
|
||||
+1
-8
@@ -9,8 +9,7 @@
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::Foundation::{BOOL, HWND};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
||||
DIB_RGB_COLORS, HBITMAP,
|
||||
@@ -199,9 +198,3 @@ fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
||||
hicon
|
||||
}
|
||||
}
|
||||
|
||||
// Silence import warnings if we end up not needing PCWSTR after later edits.
|
||||
#[allow(dead_code)]
|
||||
const _: PCWSTR = PCWSTR::null();
|
||||
|
||||
use windows::Win32::Foundation::BOOL;
|
||||
|
||||
+46
-20
@@ -133,7 +133,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
||||
|
||||
fn bucket_to_window(bucket: Bucket) -> Window {
|
||||
Window {
|
||||
utilization: bucket.utilization,
|
||||
utilization: bucket.utilization.clamp(0.0, 100.0),
|
||||
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
||||
}
|
||||
}
|
||||
@@ -163,18 +163,21 @@ fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
|
||||
now_ms >= exp_ms
|
||||
}
|
||||
|
||||
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") ---
|
||||
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]") ---
|
||||
|
||||
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let trimmed = s.split('Z').next().unwrap_or(s);
|
||||
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
|
||||
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
|
||||
// We want the original `s` for parsing time-part. Re-split on 'T'.
|
||||
let (date, time) = s
|
||||
.split_once('T')
|
||||
.map(|(d, t)| (d, t))
|
||||
.or_else(|| Some(("", "")))?;
|
||||
let _ = trimmed; // shadow; using the raw `date` + `time` below.
|
||||
let (date, time_with_offset) = s.split_once('T')?;
|
||||
|
||||
// Split the time-and-offset on the first 'Z' / '+' / '-' marker.
|
||||
let offset_pos = time_with_offset
|
||||
.char_indices()
|
||||
.find(|(_, c)| matches!(c, 'Z' | '+' | '-'))
|
||||
.map(|(i, _)| i);
|
||||
let (time, offset_str) = match offset_pos {
|
||||
Some(p) => (&time_with_offset[..p], &time_with_offset[p..]),
|
||||
None => (time_with_offset, ""),
|
||||
};
|
||||
let time = time.split_once('.').map_or(time, |(t, _)| t);
|
||||
|
||||
let date_parts: Vec<&str> = date.split('-').collect();
|
||||
if date_parts.len() != 3 {
|
||||
@@ -183,13 +186,16 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let y: u64 = date_parts[0].parse().ok()?;
|
||||
let mo: u64 = date_parts[1].parse().ok()?;
|
||||
let d: u64 = date_parts[2].parse().ok()?;
|
||||
if y < 1970 || mo == 0 || mo > 12 || d == 0 {
|
||||
return None;
|
||||
}
|
||||
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let max_day = DAYS_IN_MONTH[mo as usize] + if mo == 2 && is_leap(y) { 1 } else { 0 };
|
||||
if d > max_day {
|
||||
return None;
|
||||
}
|
||||
|
||||
let time_no_offset = time
|
||||
.split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0)))
|
||||
.next()
|
||||
.unwrap_or(time);
|
||||
let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset);
|
||||
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
|
||||
let time_parts: Vec<&str> = time.split(':').collect();
|
||||
if time_parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
@@ -197,11 +203,26 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
let mi: u64 = time_parts[1].parse().ok()?;
|
||||
let se: u64 = time_parts[2].parse().ok()?;
|
||||
|
||||
let offset_minutes: i64 = if offset_str.is_empty() || offset_str == "Z" {
|
||||
0
|
||||
} else {
|
||||
let sign: i64 = if offset_str.starts_with('+') {
|
||||
1
|
||||
} else if offset_str.starts_with('-') {
|
||||
-1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let (oh_str, om_str) = offset_str[1..].split_once(':')?;
|
||||
let oh: i64 = oh_str.parse().ok()?;
|
||||
let om: i64 = om_str.parse().ok()?;
|
||||
sign * (oh * 60 + om)
|
||||
};
|
||||
|
||||
let mut days: u64 = 0;
|
||||
for year in 1970..y {
|
||||
days += if is_leap(year) { 366 } else { 365 };
|
||||
}
|
||||
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
for month in 1..mo {
|
||||
days += DAYS_IN_MONTH[month as usize];
|
||||
if month == 2 && is_leap(y) {
|
||||
@@ -209,8 +230,13 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
}
|
||||
}
|
||||
days += d - 1;
|
||||
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
|
||||
let local_secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||
let utc_secs = (local_secs as i64) - offset_minutes * 60;
|
||||
if utc_secs < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(utc_secs as u64))
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
|
||||
@@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
||||
|
||||
fn window_from(w: ApiWindow) -> Window {
|
||||
Window {
|
||||
utilization: w.used_percent,
|
||||
utilization: w.used_percent.clamp(0.0, 100.0),
|
||||
resets_at: unix_to_systemtime(Some(w.reset_at)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,18 @@ use crate::usage::{UsageWindows, Window};
|
||||
pub fn parse_anthropic(response: &Response) -> UsageWindows {
|
||||
UsageWindows {
|
||||
primary: Window {
|
||||
utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
|
||||
utilization: (header_f64(response, "anthropic-ratelimit-unified-5h-utilization")
|
||||
* 100.0)
|
||||
.clamp(0.0, 100.0),
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-5h-reset",
|
||||
)),
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
|
||||
utilization: (header_f64(response, "anthropic-ratelimit-unified-7d-utilization")
|
||||
* 100.0)
|
||||
.clamp(0.0, 100.0),
|
||||
resets_at: unix_to_systemtime(header_i64(
|
||||
response,
|
||||
"anthropic-ratelimit-unified-7d-reset",
|
||||
|
||||
Reference in New Issue
Block a user