mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-10 06:20:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cbc3dda5b | ||
|
|
526786b902 | ||
|
|
f96d88074a | ||
|
|
e243c589a8 | ||
|
|
a5dec52aaa |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.11"
|
version = "0.1.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.11"
|
version = "0.1.13"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||||
@@ -23,6 +23,7 @@ version = "0.58"
|
|||||||
features = [
|
features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Globalization",
|
"Win32_Globalization",
|
||||||
|
"Win32_Graphics_Dwm",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
|
|||||||
+47
-63
@@ -27,9 +27,7 @@ use crate::net;
|
|||||||
use crate::os;
|
use crate::os;
|
||||||
use crate::panel::{self, PanelData};
|
use crate::panel::{self, PanelData};
|
||||||
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
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::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
|
||||||
use crate::usage::ProviderId as TrayIconKind;
|
|
||||||
use crate::tray::WM_APP_TRAY;
|
|
||||||
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
||||||
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
||||||
|
|
||||||
@@ -110,7 +108,7 @@ enum UpdateStatus {
|
|||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
msg_hwnd: SendHwnd,
|
msg_hwnd: SendHwnd,
|
||||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
is_dark: bool,
|
is_dark: bool,
|
||||||
@@ -145,6 +143,21 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
|||||||
state().lock().expect("app state mutex poisoned")
|
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 ----------
|
// ---------- Entry ----------
|
||||||
|
|
||||||
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
||||||
@@ -230,6 +243,15 @@ pub fn run(args: crate::AppArgs) {
|
|||||||
last_balloon_at: None,
|
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();
|
create_initial_bubbles();
|
||||||
refresh_tray_icons();
|
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
|
// "…" matches the in-flight/transient-error placeholder used by
|
||||||
// `apply_results`, so the bubble has visible feedback during the first
|
// `apply_results`, so the bubble has visible feedback during the first
|
||||||
// poll rather than rendering with two empty grey tracks.
|
// poll rather than rendering with two empty grey tracks.
|
||||||
@@ -365,40 +387,24 @@ unsafe extern "system" fn msg_wnd_proc(
|
|||||||
|
|
||||||
// ---------- Bubble callbacks ----------
|
// ---------- 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);
|
let data = build_panel_data(model);
|
||||||
panel::toggle(data, hwnd);
|
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);
|
show_context_menu(hwnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) {
|
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
||||||
let snap = {
|
update_settings(|s| s.settings.bubble_positions.set(model, pos));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) {
|
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
|
||||||
let snap = {
|
update_settings(|s| s.settings.bubble_size_logical = size_logical);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
let id = (id & 0xFFFF) as u16;
|
||||||
match id {
|
match id {
|
||||||
IDM_REFRESH => spawn_poll_thread(),
|
IDM_REFRESH => spawn_poll_thread(),
|
||||||
@@ -618,8 +624,7 @@ fn propagate_to_ui() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (kind, hwnd) in snap.bubbles.iter() {
|
for (kind, hwnd) in snap.bubbles.iter() {
|
||||||
let id = kind_to_provider(*kind);
|
let entry = snap.snapshots.get(kind);
|
||||||
let entry = snap.snapshots.get(&id);
|
|
||||||
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
||||||
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
||||||
// The bubble paints the percent inline inside the bar fill, so it
|
// 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 panel::is_visible() {
|
||||||
if let Some(model) = panel::current_model() {
|
if let Some(model) = panel::current_model() {
|
||||||
let id = kind_to_provider(model);
|
if let Some(provider_state) = snap.snapshots.get(&model) {
|
||||||
if let Some(provider_state) = snap.snapshots.get(&id) {
|
|
||||||
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,7 +658,7 @@ fn propagate_to_ui() {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct UiSnapshot {
|
struct UiSnapshot {
|
||||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||||
snapshots: HashMap<ProviderId, ProviderUiState>,
|
snapshots: HashMap<ProviderId, ProviderUiState>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n_strings: LocaleStrings,
|
i18n_strings: LocaleStrings,
|
||||||
@@ -663,21 +667,13 @@ struct UiSnapshot {
|
|||||||
last_poll_ok: bool,
|
last_poll_ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind_to_provider(k: TrayIconKind) -> ProviderId {
|
fn build_panel_data(model: ProviderId) -> PanelData {
|
||||||
match k {
|
|
||||||
ProviderId::Claude => ProviderId::Claude,
|
|
||||||
ProviderId::ChatGpt => ProviderId::ChatGpt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_panel_data(model: TrayIconKind) -> PanelData {
|
|
||||||
let s = lock_state();
|
let s = lock_state();
|
||||||
let Some(s) = s.as_ref() else {
|
let Some(s) = s.as_ref() else {
|
||||||
return placeholder_panel(model);
|
return placeholder_panel(model);
|
||||||
};
|
};
|
||||||
let id = kind_to_provider(model);
|
|
||||||
let strings = s.i18n.strings().clone();
|
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 {
|
PanelData {
|
||||||
model,
|
model,
|
||||||
session_pct: provider_state.windows.primary.utilization,
|
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 {
|
PanelData {
|
||||||
model,
|
model,
|
||||||
session_pct: p.windows.primary.utilization,
|
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();
|
let strings = i18n::I18n::load(None).strings().clone();
|
||||||
PanelData {
|
PanelData {
|
||||||
model,
|
model,
|
||||||
@@ -816,7 +812,7 @@ fn handle_tray_action(action: TrayAction) {
|
|||||||
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
||||||
/// posts that to every top-level window when the user toggles light/dark
|
/// posts that to every top-level window when the user toggles light/dark
|
||||||
/// in Settings, so this naturally fires once per change.
|
/// in Settings, so this naturally fires once per change.
|
||||||
pub fn recheck_theme() {
|
fn recheck_theme() {
|
||||||
let now_dark = os::theme::is_dark();
|
let now_dark = os::theme::is_dark();
|
||||||
let changed = {
|
let changed = {
|
||||||
let mut s = lock_state();
|
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 (settings, is_dark) = {
|
||||||
let mut s = lock_state();
|
let mut s = lock_state();
|
||||||
let Some(s) = s.as_mut() else {
|
let Some(s) = s.as_mut() else {
|
||||||
@@ -1249,33 +1245,21 @@ fn reset_positions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_language(_dummy: Option<()>) {
|
fn set_language(_dummy: Option<()>) {
|
||||||
let snap = {
|
update_settings(|s| {
|
||||||
let mut s = lock_state();
|
|
||||||
let Some(s) = s.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
s.i18n.set_active(None);
|
s.i18n.set_active(None);
|
||||||
s.settings.language = None;
|
s.settings.language = None;
|
||||||
s.settings.clone()
|
});
|
||||||
};
|
|
||||||
settings::save(&snap);
|
|
||||||
propagate_to_ui();
|
propagate_to_ui();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_language_by_index(idx: usize) {
|
fn set_language_by_index(idx: usize) {
|
||||||
let snap = {
|
update_settings(|s| {
|
||||||
let mut s = lock_state();
|
|
||||||
let Some(s) = s.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
||||||
if let Some(c) = code.as_deref() {
|
if let Some(c) = code.as_deref() {
|
||||||
s.i18n.set_active(Some(c));
|
s.i18n.set_active(Some(c));
|
||||||
}
|
}
|
||||||
s.settings.language = code;
|
s.settings.language = code;
|
||||||
s.settings.clone()
|
});
|
||||||
};
|
|
||||||
settings::save(&snap);
|
|
||||||
propagate_to_ui();
|
propagate_to_ui();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+403
-427
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ mod os;
|
|||||||
mod tray;
|
mod tray;
|
||||||
mod update;
|
mod update;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
mod usage_color;
|
||||||
|
|
||||||
// Application surface.
|
// Application surface.
|
||||||
mod app;
|
mod app;
|
||||||
|
|||||||
+33
-23
@@ -7,15 +7,18 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
|
|||||||
|
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
use windows::Win32::Foundation::*;
|
use windows::Win32::Foundation::*;
|
||||||
|
use windows::Win32::Graphics::Dwm::{
|
||||||
|
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||||
|
};
|
||||||
use windows::Win32::Graphics::Gdi::*;
|
use windows::Win32::Graphics::Gdi::*;
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||||
|
|
||||||
use crate::i18n::LocaleStrings;
|
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::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||||
use crate::usage::ProviderId;
|
use crate::usage::ProviderId;
|
||||||
type TrayIconKind = ProviderId;
|
|
||||||
|
|
||||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||||
const PANEL_W_LOGICAL: i32 = 280;
|
const PANEL_W_LOGICAL: i32 = 280;
|
||||||
@@ -27,7 +30,7 @@ const RIGHT_TEXT_W_LOGICAL: i32 = 96;
|
|||||||
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
||||||
|
|
||||||
pub struct PanelData {
|
pub struct PanelData {
|
||||||
pub model: TrayIconKind,
|
pub model: ProviderId,
|
||||||
pub session_pct: f64,
|
pub session_pct: f64,
|
||||||
pub session_text: String,
|
pub session_text: String,
|
||||||
pub weekly_pct: f64,
|
pub weekly_pct: f64,
|
||||||
@@ -83,7 +86,7 @@ pub fn is_visible() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_model() -> Option<TrayIconKind> {
|
pub fn current_model() -> Option<ProviderId> {
|
||||||
lock_state().as_ref().map(|p| p.data.model)
|
lock_state().as_ref().map(|p| p.data.model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +121,8 @@ pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
apply_win11_window_chrome(hwnd);
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = lock_state();
|
let mut guard = lock_state();
|
||||||
if let Some(p) = guard.as_mut() {
|
if let Some(p) = guard.as_mut() {
|
||||||
@@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
|||||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||||
PCWSTR::from_raw(class_w.as_ptr()),
|
PCWSTR::from_raw(class_w.as_ptr()),
|
||||||
PCWSTR::from_raw(title_w.as_ptr()),
|
PCWSTR::from_raw(title_w.as_ptr()),
|
||||||
WS_POPUP | WS_BORDER,
|
WS_POPUP,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
w,
|
w,
|
||||||
@@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
|
||||||
|
/// returns an error on Win10 and earlier, which we deliberately swallow so
|
||||||
|
/// the panel falls back to square corners without complaint. Idempotent:
|
||||||
|
/// DWM ignores redundant identical-value sets, so calling this on every
|
||||||
|
/// `show()` is safe.
|
||||||
|
fn apply_win11_window_chrome(hwnd: HWND) {
|
||||||
|
unsafe {
|
||||||
|
let pref = DWMWCP_ROUND;
|
||||||
|
let _ = DwmSetWindowAttribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||||
|
&pref as *const _ as *const _,
|
||||||
|
std::mem::size_of_val(&pref) as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn hide() {
|
pub fn hide() {
|
||||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||||
if let Some(hwnd) = hwnd_opt {
|
if let Some(hwnd) = hwnd_opt {
|
||||||
@@ -269,21 +291,17 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
} else {
|
} else {
|
||||||
Color::from_hex("#D6D6D6")
|
Color::from_hex("#D6D6D6")
|
||||||
};
|
};
|
||||||
let accent = bar_color_for(data.model, data.session_pct.max(data.weekly_pct), data.is_dark);
|
let session_accent =
|
||||||
|
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 {
|
unsafe {
|
||||||
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
||||||
FillRect(hdc, &rc, bg_brush);
|
FillRect(hdc, &rc, bg_brush);
|
||||||
let _ = DeleteObject(bg_brush);
|
let _ = DeleteObject(bg_brush);
|
||||||
|
|
||||||
// 4-px accent stripe matching the bubble — same provider color so the
|
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
|
||||||
// identity carries across both surfaces. Codex is theme-aware so a
|
|
||||||
// pure white stripe doesn't vanish into the light-mode background.
|
|
||||||
let stripe_color = match (data.model, data.is_dark) {
|
|
||||||
(ProviderId::Claude, _) => Color::from_hex("#D97757"),
|
|
||||||
(ProviderId::ChatGpt, true) => Color::from_hex("#FFFFFF"),
|
|
||||||
(ProviderId::ChatGpt, false) => Color::from_hex("#2A2A2A"),
|
|
||||||
};
|
|
||||||
let stripe_w = scaled(4);
|
let stripe_w = scaled(4);
|
||||||
let stripe_rect = RECT {
|
let stripe_rect = RECT {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -333,7 +351,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
&data.session_text,
|
&data.session_text,
|
||||||
text_color,
|
text_color,
|
||||||
track,
|
track,
|
||||||
accent,
|
session_accent,
|
||||||
dpi,
|
dpi,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -349,7 +367,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
&data.weekly_text,
|
&data.weekly_text,
|
||||||
text_color,
|
text_color,
|
||||||
track,
|
track,
|
||||||
accent,
|
weekly_accent,
|
||||||
dpi,
|
dpi,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -482,10 +500,6 @@ fn draw_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
|
|
||||||
crate::bubble::bar_fill_color(model, is_dark, percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_data() -> Option<PanelData> {
|
fn clone_data() -> Option<PanelData> {
|
||||||
let guard = lock_state();
|
let guard = lock_state();
|
||||||
let p = guard.as_ref()?;
|
let p = guard.as_ref()?;
|
||||||
@@ -528,7 +542,3 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
|||||||
}
|
}
|
||||||
(x, y)
|
(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::bubble::DEFAULT_BUBBLE_SIZE;
|
||||||
use crate::usage::ProviderId;
|
use crate::usage::ProviderId;
|
||||||
type TrayIconKind = ProviderId;
|
|
||||||
|
|
||||||
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
// 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
|
// edge still passes the validator, but a position fully on a disconnected
|
||||||
@@ -52,19 +51,19 @@ pub struct BubblePositions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BubblePositions {
|
impl BubblePositions {
|
||||||
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
|
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> {
|
||||||
match model {
|
match model {
|
||||||
ProviderId::Claude => self.claude,
|
ProviderId::Claude => self.claude,
|
||||||
ProviderId::ChatGpt => self.codex,
|
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 {
|
match model {
|
||||||
ProviderId::Claude => self.claude = Some(pos),
|
ProviderId::Claude => self.claude = Some(pos),
|
||||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn reset(&mut self, model: TrayIconKind) {
|
pub fn reset(&mut self, model: ProviderId) {
|
||||||
match model {
|
match model {
|
||||||
ProviderId::Claude => self.claude = None,
|
ProviderId::Claude => self.claude = None,
|
||||||
ProviderId::ChatGpt => self.codex = None,
|
ProviderId::ChatGpt => self.codex = None,
|
||||||
|
|||||||
+8
-36
@@ -9,8 +9,7 @@
|
|||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
|
|
||||||
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||||
use windows::core::PCWSTR;
|
use windows::Win32::Foundation::{BOOL, HWND};
|
||||||
use windows::Win32::Foundation::HWND;
|
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
||||||
DIB_RGB_COLORS, HBITMAP,
|
DIB_RGB_COLORS, HBITMAP,
|
||||||
@@ -66,7 +65,7 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
|||||||
if let Some(p) = percent {
|
if let Some(p) = percent {
|
||||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||||
if sweep > 0.0 {
|
if sweep > 0.0 {
|
||||||
let fill = usage_color(p);
|
let fill = usage_color(kind, p);
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||||
paint.anti_alias = true;
|
paint.anti_alias = true;
|
||||||
@@ -116,33 +115,12 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage_color(percent: f64) -> [u8; 3] {
|
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
|
||||||
// Color gradient: soft orange (low usage) → red (near-cap).
|
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
|
||||||
let stops: [(f64, [u8; 3]); 5] = [
|
/// ring readable (Codex sweep stays white instead of charcoal).
|
||||||
(0.0, [0xD9, 0x77, 0x57]),
|
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||||
(50.0, [0xD9, 0x77, 0x57]),
|
let c = crate::usage_color::bar_fill_color(kind, true, percent);
|
||||||
(75.0, [0xCC, 0x8C, 0x20]),
|
[c.r, c.g, c.b]
|
||||||
(90.0, [0xC4, 0x50, 0x20]),
|
|
||||||
(100.0, [0xB8, 0x20, 0x20]),
|
|
||||||
];
|
|
||||||
for pair in stops.windows(2) {
|
|
||||||
let (a_p, a_c) = pair[0];
|
|
||||||
let (b_p, b_c) = pair[1];
|
|
||||||
if percent <= b_p {
|
|
||||||
let span = (b_p - a_p).max(f64::EPSILON);
|
|
||||||
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
|
|
||||||
return [
|
|
||||||
lerp(a_c[0], b_c[0], t),
|
|
||||||
lerp(a_c[1], b_c[1], t),
|
|
||||||
lerp(a_c[2], b_c[2], t),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stops[stops.len() - 1].1
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lerp(a: u8, b: u8, t: f64) -> u8 {
|
|
||||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Pixmap → HICON ----------
|
// ---------- Pixmap → HICON ----------
|
||||||
@@ -220,9 +198,3 @@ fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
|||||||
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 {
|
fn bucket_to_window(bucket: Bucket) -> Window {
|
||||||
Window {
|
Window {
|
||||||
utilization: bucket.utilization,
|
utilization: bucket.utilization.clamp(0.0, 100.0),
|
||||||
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
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
|
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> {
|
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||||
let trimmed = s.split('Z').next().unwrap_or(s);
|
let (date, time_with_offset) = s.split_once('T')?;
|
||||||
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
|
|
||||||
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
|
// Split the time-and-offset on the first 'Z' / '+' / '-' marker.
|
||||||
// We want the original `s` for parsing time-part. Re-split on 'T'.
|
let offset_pos = time_with_offset
|
||||||
let (date, time) = s
|
.char_indices()
|
||||||
.split_once('T')
|
.find(|(_, c)| matches!(c, 'Z' | '+' | '-'))
|
||||||
.map(|(d, t)| (d, t))
|
.map(|(i, _)| i);
|
||||||
.or_else(|| Some(("", "")))?;
|
let (time, offset_str) = match offset_pos {
|
||||||
let _ = trimmed; // shadow; using the raw `date` + `time` below.
|
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();
|
let date_parts: Vec<&str> = date.split('-').collect();
|
||||||
if date_parts.len() != 3 {
|
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 y: u64 = date_parts[0].parse().ok()?;
|
||||||
let mo: u64 = date_parts[1].parse().ok()?;
|
let mo: u64 = date_parts[1].parse().ok()?;
|
||||||
let d: u64 = date_parts[2].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
|
let time_parts: Vec<&str> = time.split(':').collect();
|
||||||
.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();
|
|
||||||
if time_parts.len() != 3 {
|
if time_parts.len() != 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -197,11 +203,26 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
|||||||
let mi: u64 = time_parts[1].parse().ok()?;
|
let mi: u64 = time_parts[1].parse().ok()?;
|
||||||
let se: u64 = time_parts[2].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;
|
let mut days: u64 = 0;
|
||||||
for year in 1970..y {
|
for year in 1970..y {
|
||||||
days += if is_leap(year) { 366 } else { 365 };
|
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 {
|
for month in 1..mo {
|
||||||
days += DAYS_IN_MONTH[month as usize];
|
days += DAYS_IN_MONTH[month as usize];
|
||||||
if month == 2 && is_leap(y) {
|
if month == 2 && is_leap(y) {
|
||||||
@@ -209,8 +230,13 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
days += d - 1;
|
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 {
|
fn is_leap(year: u64) -> bool {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
|||||||
|
|
||||||
fn window_from(w: ApiWindow) -> Window {
|
fn window_from(w: ApiWindow) -> Window {
|
||||||
Window {
|
Window {
|
||||||
utilization: w.used_percent,
|
utilization: w.used_percent.clamp(0.0, 100.0),
|
||||||
resets_at: unix_to_systemtime(Some(w.reset_at)),
|
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 {
|
pub fn parse_anthropic(response: &Response) -> UsageWindows {
|
||||||
UsageWindows {
|
UsageWindows {
|
||||||
primary: Window {
|
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(
|
resets_at: unix_to_systemtime(header_i64(
|
||||||
response,
|
response,
|
||||||
"anthropic-ratelimit-unified-5h-reset",
|
"anthropic-ratelimit-unified-5h-reset",
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
secondary: Window {
|
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(
|
resets_at: unix_to_systemtime(header_i64(
|
||||||
response,
|
response,
|
||||||
"anthropic-ratelimit-unified-7d-reset",
|
"anthropic-ratelimit-unified-7d-reset",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Shared usage→color ramp used by the floating bubble, the expanded panel,
|
||||||
|
// and the tray badge. Keeping the function in one place ensures the three
|
||||||
|
// surfaces never disagree about what "78% used" looks like.
|
||||||
|
|
||||||
|
use crate::os::Rgb as Color;
|
||||||
|
use crate::usage::ProviderId;
|
||||||
|
|
||||||
|
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex =
|
||||||
|
/// OpenAI brand teal `#10A37F`, used consistently across dark and light
|
||||||
|
/// themes so the badge/bubble/panel never disagree on Codex identity.
|
||||||
|
pub fn accent_color_for(model: ProviderId, _is_dark: bool) -> Color {
|
||||||
|
match model {
|
||||||
|
ProviderId::Claude => Color::from_hex("#D97757"),
|
||||||
|
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
|
||||||
|
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the
|
||||||
|
/// warning bands are theme-aware so light-mode amber stays readable against
|
||||||
|
/// the `#F3F3F3` background.
|
||||||
|
///
|
||||||
|
/// - <60% → provider accent
|
||||||
|
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
|
||||||
|
/// - 80–95% → red `#C45020`
|
||||||
|
/// - ≥95% → deep red `#A01818` — paired with pulse animation
|
||||||
|
pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
|
||||||
|
if percent < 60.0 {
|
||||||
|
accent_color_for(model, is_dark)
|
||||||
|
} else if percent < 80.0 {
|
||||||
|
if is_dark {
|
||||||
|
Color::from_hex("#E0A040")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#B47A20")
|
||||||
|
}
|
||||||
|
} else if percent < 95.0 {
|
||||||
|
Color::from_hex("#C45020")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#A01818")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user