mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 18:17:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3d01f36d2 | ||
|
|
7bbf80e5f7 | ||
|
|
8cbc3dda5b | ||
|
|
526786b902 | ||
|
|
f96d88074a | ||
|
|
e243c589a8 | ||
|
|
a5dec52aaa | ||
|
|
4e0f32591b | ||
|
|
3e1af07ec2 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.10"
|
||||
version = "0.1.14"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.10"
|
||||
version = "0.1.14"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
@@ -23,6 +23,7 @@ version = "0.58"
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Globalization",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_Shell",
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
|
||||
+404
-427
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
code = "de"
|
||||
native_name = "Deutsch"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Aktualisieren"
|
||||
update_frequency = "Aktualisierungsintervall"
|
||||
one_minute = "1 Minute"
|
||||
five_minutes = "5 Minuten"
|
||||
fifteen_minutes = "15 Minuten"
|
||||
one_hour = "1 Stunde"
|
||||
models = "Modelle"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Einstellungen"
|
||||
start_with_windows = "Mit Windows starten"
|
||||
reset_position = "Position zurücksetzen"
|
||||
language = "Sprache"
|
||||
system_default = "Systemstandard"
|
||||
check_for_updates = "Nach Updates suchen"
|
||||
checking_for_updates = "Suche läuft…"
|
||||
up_to_date = "Aktuell"
|
||||
update_failed = "Update fehlgeschlagen"
|
||||
applying_update = "Update wird angewendet…"
|
||||
update_available = "Update verfügbar"
|
||||
update_via_winget = "über WinGet"
|
||||
auto_update_check = "Automatische Updateprüfung"
|
||||
auto_check_disabled = "Deaktiviert"
|
||||
auto_check_hourly = "Stündlich"
|
||||
auto_check_daily = "Täglich"
|
||||
auto_check_weekly = "Wöchentlich"
|
||||
exit = "Beenden"
|
||||
restart = "Neu starten"
|
||||
show_widget = "Widget anzeigen"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "jetzt"
|
||||
day_suffix = "T"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code-Sitzung abgelaufen"
|
||||
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||
threshold_80_body = "5-Stunden-Limit naht."
|
||||
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
|
||||
update_applied_title = "Update angewendet"
|
||||
update_applied_body = "Aktualisiert auf v"
|
||||
update_rollback_failed_body = "Update fehlgeschlagen. Deine ursprüngliche Binärdatei liegt unter: "
|
||||
@@ -1,49 +0,0 @@
|
||||
code = "es"
|
||||
native_name = "Español"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Actualizar"
|
||||
update_frequency = "Frecuencia de actualización"
|
||||
one_minute = "1 minuto"
|
||||
five_minutes = "5 minutos"
|
||||
fifteen_minutes = "15 minutos"
|
||||
one_hour = "1 hora"
|
||||
models = "Modelos"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Ajustes"
|
||||
start_with_windows = "Iniciar con Windows"
|
||||
reset_position = "Restablecer posición"
|
||||
language = "Idioma"
|
||||
system_default = "Predeterminado del sistema"
|
||||
check_for_updates = "Buscar actualizaciones"
|
||||
checking_for_updates = "Buscando actualizaciones…"
|
||||
up_to_date = "Al día"
|
||||
update_failed = "Actualización fallida"
|
||||
applying_update = "Aplicando actualización…"
|
||||
update_available = "Actualización disponible"
|
||||
update_via_winget = "vía WinGet"
|
||||
auto_update_check = "Búsqueda automática de actualizaciones"
|
||||
auto_check_disabled = "Desactivada"
|
||||
auto_check_hourly = "Cada hora"
|
||||
auto_check_daily = "Cada día"
|
||||
auto_check_weekly = "Cada semana"
|
||||
exit = "Salir"
|
||||
restart = "Reiniciar"
|
||||
show_widget = "Mostrar widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "ahora"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Sesión de Claude Code caducada"
|
||||
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
chatgpt_token_expired_title = "Sesión de Codex caducada"
|
||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
||||
threshold_80_body = "Cerca del límite de 5 horas."
|
||||
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
|
||||
update_applied_title = "Actualización aplicada"
|
||||
update_applied_body = "Actualizado a v"
|
||||
update_rollback_failed_body = "Actualización fallida. Tu binario original está guardado en: "
|
||||
@@ -1,49 +0,0 @@
|
||||
code = "fr"
|
||||
native_name = "Français"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Actualiser"
|
||||
update_frequency = "Fréquence de mise à jour"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 heure"
|
||||
models = "Modèles"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Paramètres"
|
||||
start_with_windows = "Lancer avec Windows"
|
||||
reset_position = "Réinitialiser la position"
|
||||
language = "Langue"
|
||||
system_default = "Paramètre système"
|
||||
check_for_updates = "Rechercher des mises à jour"
|
||||
checking_for_updates = "Recherche en cours…"
|
||||
up_to_date = "À jour"
|
||||
update_failed = "Mise à jour échouée"
|
||||
applying_update = "Mise à jour en cours…"
|
||||
update_available = "Mise à jour disponible"
|
||||
update_via_winget = "via WinGet"
|
||||
auto_update_check = "Vérification automatique des mises à jour"
|
||||
auto_check_disabled = "Désactivée"
|
||||
auto_check_hourly = "Toutes les heures"
|
||||
auto_check_daily = "Quotidienne"
|
||||
auto_check_weekly = "Hebdomadaire"
|
||||
exit = "Quitter"
|
||||
restart = "Redémarrer"
|
||||
show_widget = "Afficher le widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7j"
|
||||
now = "maintenant"
|
||||
day_suffix = "j"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Session Claude Code expirée"
|
||||
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
chatgpt_token_expired_title = "Session Codex expirée"
|
||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||
threshold_80_body = "Approche de la limite de 5 heures."
|
||||
threshold_95_body = "Limite proche — pensez à lever le pied."
|
||||
update_applied_title = "Mise à jour appliquée"
|
||||
update_applied_body = "Mis à jour vers v"
|
||||
update_rollback_failed_body = "Échec de la mise à jour. Votre binaire d'origine est enregistré à : "
|
||||
@@ -1,49 +0,0 @@
|
||||
code = "nl"
|
||||
native_name = "Nederlands"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Vernieuwen"
|
||||
update_frequency = "Bijwerkfrequentie"
|
||||
one_minute = "1 minuut"
|
||||
five_minutes = "5 minuten"
|
||||
fifteen_minutes = "15 minuten"
|
||||
one_hour = "1 uur"
|
||||
models = "Modellen"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Instellingen"
|
||||
start_with_windows = "Starten met Windows"
|
||||
reset_position = "Positie herstellen"
|
||||
language = "Taal"
|
||||
system_default = "Systeemstandaard"
|
||||
check_for_updates = "Controleren op updates"
|
||||
checking_for_updates = "Bezig met controleren…"
|
||||
up_to_date = "Up-to-date"
|
||||
update_failed = "Update mislukt"
|
||||
applying_update = "Update toepassen…"
|
||||
update_available = "Update beschikbaar"
|
||||
update_via_winget = "via WinGet"
|
||||
auto_update_check = "Automatische updatecontrole"
|
||||
auto_check_disabled = "Uitgeschakeld"
|
||||
auto_check_hourly = "Per uur"
|
||||
auto_check_daily = "Dagelijks"
|
||||
auto_check_weekly = "Wekelijks"
|
||||
exit = "Afsluiten"
|
||||
restart = "Opnieuw starten"
|
||||
show_widget = "Widget tonen"
|
||||
session_window = "5u"
|
||||
weekly_window = "7d"
|
||||
now = "nu"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "u"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code-sessie verlopen"
|
||||
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||
threshold_80_body = "Je nadert de 5-uurslimiet."
|
||||
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
|
||||
update_applied_title = "Update toegepast"
|
||||
update_applied_body = "Bijgewerkt naar v"
|
||||
update_rollback_failed_body = "Update mislukt. Je oorspronkelijke bestand staat op: "
|
||||
@@ -0,0 +1,49 @@
|
||||
code = "vi"
|
||||
native_name = "Tiếng Việt"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Làm mới"
|
||||
update_frequency = "Tần suất cập nhật"
|
||||
one_minute = "1 phút"
|
||||
five_minutes = "5 phút"
|
||||
fifteen_minutes = "15 phút"
|
||||
one_hour = "1 giờ"
|
||||
models = "Mô hình"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Cài đặt"
|
||||
start_with_windows = "Khởi động cùng Windows"
|
||||
reset_position = "Đặt lại vị trí"
|
||||
language = "Ngôn ngữ"
|
||||
system_default = "Mặc định hệ thống"
|
||||
check_for_updates = "Kiểm tra cập nhật"
|
||||
checking_for_updates = "Đang kiểm tra cập nhật…"
|
||||
up_to_date = "Đã là phiên bản mới nhất"
|
||||
update_failed = "Cập nhật thất bại"
|
||||
applying_update = "Đang áp dụng cập nhật…"
|
||||
update_available = "Có bản cập nhật mới"
|
||||
update_via_winget = "qua WinGet"
|
||||
auto_update_check = "Tự động kiểm tra cập nhật"
|
||||
auto_check_disabled = "Tắt"
|
||||
auto_check_hourly = "Mỗi giờ"
|
||||
auto_check_daily = "Hằng ngày"
|
||||
auto_check_weekly = "Hằng tuần"
|
||||
exit = "Thoát"
|
||||
restart = "Khởi động lại"
|
||||
show_widget = "Hiện widget"
|
||||
session_window = "5g"
|
||||
weekly_window = "7n"
|
||||
now = "ngay"
|
||||
day_suffix = "n"
|
||||
hour_suffix = "g"
|
||||
minute_suffix = "p"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Phiên Claude Code đã hết hạn"
|
||||
token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
|
||||
chatgpt_token_expired_title = "Phiên Codex đã hết hạn"
|
||||
chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
|
||||
threshold_80_body = "Sắp chạm giới hạn 5 giờ."
|
||||
threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc."
|
||||
update_applied_title = "Đã áp dụng cập nhật"
|
||||
update_applied_body = "Đã cập nhật lên v"
|
||||
update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: "
|
||||
+1
-4
@@ -87,12 +87,9 @@ struct LocaleFile {
|
||||
|
||||
const RAW_LOCALES: &[(&str, &str)] = &[
|
||||
("en", include_str!("locales/en.toml")),
|
||||
("nl", include_str!("locales/nl.toml")),
|
||||
("es", include_str!("locales/es.toml")),
|
||||
("fr", include_str!("locales/fr.toml")),
|
||||
("de", include_str!("locales/de.toml")),
|
||||
("ja", include_str!("locales/ja.toml")),
|
||||
("ko", include_str!("locales/ko.toml")),
|
||||
("vi", include_str!("locales/vi.toml")),
|
||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||
];
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ mod os;
|
||||
mod tray;
|
||||
mod update;
|
||||
mod usage;
|
||||
mod usage_color;
|
||||
|
||||
// Application surface.
|
||||
mod app;
|
||||
|
||||
+33
-23
@@ -7,15 +7,18 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
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;
|
||||
@@ -27,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,
|
||||
@@ -83,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)
|
||||
}
|
||||
|
||||
@@ -118,6 +121,8 @@ pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
||||
},
|
||||
};
|
||||
|
||||
apply_win11_window_chrome(hwnd);
|
||||
|
||||
{
|
||||
let mut guard = lock_state();
|
||||
if let Some(p) = guard.as_mut() {
|
||||
@@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||
PCWSTR::from_raw(class_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
WS_POPUP | WS_BORDER,
|
||||
WS_POPUP,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
@@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<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() {
|
||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
@@ -269,21 +291,17 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
let accent = bar_color_for(data.model, data.session_pct.max(data.weekly_pct), data.is_dark);
|
||||
let session_accent =
|
||||
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()));
|
||||
FillRect(hdc, &rc, bg_brush);
|
||||
let _ = DeleteObject(bg_brush);
|
||||
|
||||
// 4-px accent stripe matching the bubble — same provider color so the
|
||||
// identity carries across both surfaces. Codex is theme-aware so a
|
||||
// pure white stripe doesn't vanish into the light-mode background.
|
||||
let stripe_color = match (data.model, data.is_dark) {
|
||||
(ProviderId::Claude, _) => Color::from_hex("#D97757"),
|
||||
(ProviderId::ChatGpt, true) => Color::from_hex("#FFFFFF"),
|
||||
(ProviderId::ChatGpt, false) => Color::from_hex("#2A2A2A"),
|
||||
};
|
||||
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
|
||||
let stripe_w = scaled(4);
|
||||
let stripe_rect = RECT {
|
||||
left: 0,
|
||||
@@ -333,7 +351,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
&data.session_text,
|
||||
text_color,
|
||||
track,
|
||||
accent,
|
||||
session_accent,
|
||||
dpi,
|
||||
);
|
||||
|
||||
@@ -349,7 +367,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
&data.weekly_text,
|
||||
text_color,
|
||||
track,
|
||||
accent,
|
||||
weekly_accent,
|
||||
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> {
|
||||
let guard = lock_state();
|
||||
let p = guard.as_ref()?;
|
||||
@@ -528,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,
|
||||
|
||||
+8
-36
@@ -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,
|
||||
@@ -66,7 +65,7 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
if let Some(p) = percent {
|
||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||
if sweep > 0.0 {
|
||||
let fill = usage_color(p);
|
||||
let fill = usage_color(kind, p);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||
paint.anti_alias = true;
|
||||
@@ -116,33 +115,12 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_color(percent: f64) -> [u8; 3] {
|
||||
// Color gradient: soft orange (low usage) → red (near-cap).
|
||||
let stops: [(f64, [u8; 3]); 5] = [
|
||||
(0.0, [0xD9, 0x77, 0x57]),
|
||||
(50.0, [0xD9, 0x77, 0x57]),
|
||||
(75.0, [0xCC, 0x8C, 0x20]),
|
||||
(90.0, [0xC4, 0x50, 0x20]),
|
||||
(100.0, [0xB8, 0x20, 0x20]),
|
||||
];
|
||||
for pair in stops.windows(2) {
|
||||
let (a_p, a_c) = pair[0];
|
||||
let (b_p, b_c) = pair[1];
|
||||
if percent <= b_p {
|
||||
let span = (b_p - a_p).max(f64::EPSILON);
|
||||
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
|
||||
return [
|
||||
lerp(a_c[0], b_c[0], t),
|
||||
lerp(a_c[1], b_c[1], t),
|
||||
lerp(a_c[2], b_c[2], t),
|
||||
];
|
||||
}
|
||||
}
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
|
||||
fn lerp(a: u8, b: u8, t: f64) -> u8 {
|
||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
||||
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
|
||||
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
|
||||
/// ring readable (Codex sweep stays white instead of charcoal).
|
||||
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||
let c = crate::usage_color::bar_fill_color(kind, true, percent);
|
||||
[c.r, c.g, c.b]
|
||||
}
|
||||
|
||||
// ---------- Pixmap → HICON ----------
|
||||
@@ -220,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",
|
||||
|
||||
@@ -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