mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-13 04:15:04 +00:00
feat: clean-room rewrite — replace ported modules with original implementations
Every Rust module under src/ that previously contained upstream-derivative
code has been replaced by a from-scratch implementation:
diag/ log + simplelog file appender (was: diagnose.rs)
os/ color, dpi, registry, string, theme (was: theme.rs, native_interop.rs)
net/ WinHTTP-based HTTP client (was: ureq + native-tls)
i18n/ TOML-embedded locale tables (was: localization/*.rs)
usage/ trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh
orchestrator + registry (was: poller.rs, models.rs)
creds/ trait CredentialSource + local/WSL/Codex impls (was: poller.rs)
tray/ stateless tray manager + tiny-skia anti-aliased badge renderer
(was: tray_icon.rs)
update/ release fetch + inline cmd /c handoff installer
(was: updater.rs's helper-exe pattern)
Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to
the new modules. main.rs declares only the new modules.
NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit
inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls
+ winres in favour of log + simplelog + thiserror + toml + tiny-skia +
embed-resource. Build script swapped to embed-resource via res/icon.rc.
External contracts preserved unchanged: Anthropic + ChatGPT endpoints and
headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging
via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows
registry path for startup, single-instance mutex name.
Phase docs: plans/260516-0707-cleanroom-rewrite/.
This commit is contained in:
1 parent
c0f3e3f860
commit
aa6217d2cf
72 files changed
+6265
-3788
No files matched your search
@@ -0,0 +1,73 @@
|
||||
// Discover the user's preferred Windows UI language.
|
||||
//
|
||||
// We try three sources in priority order and return the first non-empty
|
||||
// result. Callers normalise the returned BCP-47-ish code against the
|
||||
// list of locales we actually ship.
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Globalization::{
|
||||
GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages,
|
||||
LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME,
|
||||
};
|
||||
|
||||
/// First non-empty locale code from the user's preferences. May be
|
||||
/// `Some("en-US")` style; callers do prefix normalisation.
|
||||
pub fn detect_system_locale() -> Option<String> {
|
||||
preferred_ui()
|
||||
.into_iter()
|
||||
.next()
|
||||
.or_else(default_ui_language)
|
||||
.or_else(default_locale_name)
|
||||
}
|
||||
|
||||
fn preferred_ui() -> Vec<String> {
|
||||
unsafe {
|
||||
let mut count: u32 = 0;
|
||||
let mut buf_len: u32 = 0;
|
||||
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len)
|
||||
.is_err()
|
||||
|| buf_len == 0
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut buffer = vec![0u16; buf_len as usize];
|
||||
if GetUserPreferredUILanguages(
|
||||
MUI_LANGUAGE_NAME,
|
||||
&mut count,
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
&mut buf_len,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
buffer
|
||||
.split(|u| *u == 0)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from_utf16_lossy)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ui_language() -> Option<String> {
|
||||
unsafe {
|
||||
let lcid = GetUserDefaultUILanguage();
|
||||
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_locale_name() -> Option<String> {
|
||||
unsafe {
|
||||
let mut buf = [0u16; MAX_LOCALE_NAME as usize];
|
||||
let len = GetUserDefaultLocaleName(&mut buf);
|
||||
if len <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf[..(len as usize - 1)]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
exit = "Beenden"
|
||||
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."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "en"
|
||||
native_name = "English"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "Refresh"
|
||||
update_frequency = "Update frequency"
|
||||
one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 hour"
|
||||
models = "Models"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
language = "Language"
|
||||
system_default = "System default"
|
||||
check_for_updates = "Check for updates"
|
||||
checking_for_updates = "Checking for updates…"
|
||||
up_to_date = "Up to date"
|
||||
update_failed = "Update failed"
|
||||
applying_update = "Applying update…"
|
||||
update_available = "Update available"
|
||||
update_via_winget = "via WinGet"
|
||||
exit = "Exit"
|
||||
show_widget = "Show widget"
|
||||
session_window = "5h"
|
||||
weekly_window = "7d"
|
||||
now = "now"
|
||||
day_suffix = "d"
|
||||
hour_suffix = "h"
|
||||
minute_suffix = "m"
|
||||
second_suffix = "s"
|
||||
token_expired_title = "Claude Code session expired"
|
||||
token_expired_body = "Sign in again to keep tracking your usage."
|
||||
chatgpt_token_expired_title = "Codex session expired"
|
||||
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
|
||||
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
exit = "Salir"
|
||||
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."
|
||||
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
exit = "Quitter"
|
||||
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."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "ja"
|
||||
native_name = "日本語"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "更新"
|
||||
update_frequency = "更新間隔"
|
||||
one_minute = "1分"
|
||||
five_minutes = "5分"
|
||||
fifteen_minutes = "15分"
|
||||
one_hour = "1時間"
|
||||
models = "モデル"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "Windows起動時に開始"
|
||||
reset_position = "位置をリセット"
|
||||
language = "言語"
|
||||
system_default = "システム既定"
|
||||
check_for_updates = "更新を確認"
|
||||
checking_for_updates = "確認中…"
|
||||
up_to_date = "最新です"
|
||||
update_failed = "更新に失敗しました"
|
||||
applying_update = "更新を適用中…"
|
||||
update_available = "更新あり"
|
||||
update_via_winget = "WinGet経由"
|
||||
exit = "終了"
|
||||
show_widget = "ウィジェットを表示"
|
||||
session_window = "5時間"
|
||||
weekly_window = "7日"
|
||||
now = "今"
|
||||
day_suffix = "日"
|
||||
hour_suffix = "時"
|
||||
minute_suffix = "分"
|
||||
second_suffix = "秒"
|
||||
token_expired_title = "Claude Codeのセッションが切れました"
|
||||
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
chatgpt_token_expired_title = "Codexのセッションが切れました"
|
||||
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "ko"
|
||||
native_name = "한국어"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "새로 고침"
|
||||
update_frequency = "업데이트 주기"
|
||||
one_minute = "1분"
|
||||
five_minutes = "5분"
|
||||
fifteen_minutes = "15분"
|
||||
one_hour = "1시간"
|
||||
models = "모델"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "설정"
|
||||
start_with_windows = "Windows 시작 시 실행"
|
||||
reset_position = "위치 초기화"
|
||||
language = "언어"
|
||||
system_default = "시스템 기본값"
|
||||
check_for_updates = "업데이트 확인"
|
||||
checking_for_updates = "확인 중…"
|
||||
up_to_date = "최신 상태"
|
||||
update_failed = "업데이트 실패"
|
||||
applying_update = "업데이트 적용 중…"
|
||||
update_available = "업데이트 있음"
|
||||
update_via_winget = "WinGet 사용"
|
||||
exit = "종료"
|
||||
show_widget = "위젯 표시"
|
||||
session_window = "5시간"
|
||||
weekly_window = "7일"
|
||||
now = "지금"
|
||||
day_suffix = "일"
|
||||
hour_suffix = "시간"
|
||||
minute_suffix = "분"
|
||||
second_suffix = "초"
|
||||
token_expired_title = "Claude Code 세션 만료"
|
||||
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
chatgpt_token_expired_title = "Codex 세션 만료"
|
||||
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
exit = "Afsluiten"
|
||||
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."
|
||||
@@ -0,0 +1,38 @@
|
||||
code = "zh-TW"
|
||||
native_name = "繁體中文"
|
||||
|
||||
window_title = "Claude Code Usage Bubble"
|
||||
refresh = "重新整理"
|
||||
update_frequency = "更新頻率"
|
||||
one_minute = "1 分鐘"
|
||||
five_minutes = "5 分鐘"
|
||||
fifteen_minutes = "15 分鐘"
|
||||
one_hour = "1 小時"
|
||||
models = "模型"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "隨 Windows 啟動"
|
||||
reset_position = "重設位置"
|
||||
language = "語言"
|
||||
system_default = "系統預設"
|
||||
check_for_updates = "檢查更新"
|
||||
checking_for_updates = "檢查中…"
|
||||
up_to_date = "已是最新"
|
||||
update_failed = "更新失敗"
|
||||
applying_update = "正在套用更新…"
|
||||
update_available = "有可用更新"
|
||||
update_via_winget = "透過 WinGet"
|
||||
exit = "結束"
|
||||
show_widget = "顯示小工具"
|
||||
session_window = "5 小時"
|
||||
weekly_window = "7 日"
|
||||
now = "現在"
|
||||
day_suffix = "日"
|
||||
hour_suffix = "時"
|
||||
minute_suffix = "分"
|
||||
second_suffix = "秒"
|
||||
token_expired_title = "Claude Code 工作階段已過期"
|
||||
token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
chatgpt_token_expired_title = "Codex 工作階段已過期"
|
||||
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
// Embedded TOML-based localisation.
|
||||
//
|
||||
// Each supported language lives in `locales/<code>.toml`. At startup we
|
||||
// `include_str!` every file, parse them with `toml`, and stash them in a
|
||||
// HashMap keyed by language code. The active language defaults to whatever
|
||||
// Windows reports for the user's preferred UI language; the menu lets the
|
||||
// user override that.
|
||||
//
|
||||
// Adding a translation: copy `en.toml` to `<code>.toml`, translate the
|
||||
// strings, then add one `include_str!` entry to `RAW_LOCALES` below.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod detect;
|
||||
|
||||
const FALLBACK_CODE: &str = "en";
|
||||
|
||||
/// The strings every UI module needs. Field names map 1:1 to TOML keys.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LocaleStrings {
|
||||
pub window_title: String,
|
||||
pub refresh: String,
|
||||
pub update_frequency: String,
|
||||
pub one_minute: String,
|
||||
pub five_minutes: String,
|
||||
pub fifteen_minutes: String,
|
||||
pub one_hour: String,
|
||||
pub models: String,
|
||||
pub claude_label: String,
|
||||
pub chatgpt_label: String,
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
pub language: String,
|
||||
pub system_default: String,
|
||||
pub check_for_updates: String,
|
||||
pub checking_for_updates: String,
|
||||
pub up_to_date: String,
|
||||
pub update_failed: String,
|
||||
pub applying_update: String,
|
||||
pub update_available: String,
|
||||
pub update_via_winget: String,
|
||||
pub exit: String,
|
||||
pub show_widget: String,
|
||||
pub session_window: String,
|
||||
pub weekly_window: String,
|
||||
pub now: String,
|
||||
pub day_suffix: String,
|
||||
pub hour_suffix: String,
|
||||
pub minute_suffix: String,
|
||||
pub second_suffix: String,
|
||||
pub token_expired_title: String,
|
||||
pub token_expired_body: String,
|
||||
pub chatgpt_token_expired_title: String,
|
||||
pub chatgpt_token_expired_body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LocaleFile {
|
||||
code: String,
|
||||
native_name: String,
|
||||
#[serde(flatten)]
|
||||
strings: LocaleStrings,
|
||||
}
|
||||
|
||||
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")),
|
||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||
];
|
||||
|
||||
pub struct I18n {
|
||||
/// Sorted by code so menus list languages deterministically.
|
||||
available: BTreeMap<String, (String, LocaleStrings)>,
|
||||
active: String,
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
/// Load all embedded TOML files and pick an active language.
|
||||
///
|
||||
/// `requested` overrides system detection. `None` means "ask Windows".
|
||||
pub fn load(requested: Option<&str>) -> Self {
|
||||
let mut available = BTreeMap::new();
|
||||
for (code, body) in RAW_LOCALES {
|
||||
match toml::from_str::<LocaleFile>(body) {
|
||||
Ok(file) => {
|
||||
available.insert(file.code.clone(), (file.native_name, file.strings));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("failed to parse locale {code}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if !available.contains_key(FALLBACK_CODE) {
|
||||
// Embedded TOMLs are validated by tests; this should never
|
||||
// happen in practice. Fall through with whatever we have.
|
||||
log::error!("fallback locale '{FALLBACK_CODE}' missing");
|
||||
}
|
||||
|
||||
let active = requested
|
||||
.and_then(|c| normalise(c, &available))
|
||||
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available)))
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
|
||||
Self { available, active }
|
||||
}
|
||||
|
||||
pub fn strings(&self) -> &LocaleStrings {
|
||||
self.available
|
||||
.get(&self.active)
|
||||
.map(|(_, s)| s)
|
||||
.unwrap_or_else(|| {
|
||||
// Defensive: if `active` was set to something unavailable
|
||||
// (shouldn't happen given `load` validates) — fall back.
|
||||
&self
|
||||
.available
|
||||
.get(FALLBACK_CODE)
|
||||
.expect("fallback locale must exist")
|
||||
.1
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_code(&self) -> &str {
|
||||
&self.active
|
||||
}
|
||||
|
||||
/// Iterate `(code, native_name)` pairs in stable order.
|
||||
pub fn available(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.available
|
||||
.iter()
|
||||
.map(|(code, (name, _))| (code.as_str(), name.as_str()))
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, requested: Option<&str>) {
|
||||
let new_active = requested
|
||||
.and_then(|c| normalise(c, &self.available))
|
||||
.or_else(|| {
|
||||
detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))
|
||||
})
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
self.active = new_active;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied or system-supplied locale code to one we have.
|
||||
///
|
||||
/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive
|
||||
/// fallback: exact → ASCII-lower exact → prefix match.
|
||||
fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>) -> Option<String> {
|
||||
let cleaned = input.trim().replace('_', "-");
|
||||
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") {
|
||||
return None;
|
||||
}
|
||||
// Exact (case-insensitive)
|
||||
for key in available.keys() {
|
||||
if key.eq_ignore_ascii_case(&cleaned) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
// Special-case: Traditional Chinese variants → zh-TW
|
||||
let lower = cleaned.to_ascii_lowercase();
|
||||
if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) {
|
||||
if available.contains_key("zh-TW") {
|
||||
return Some("zh-TW".to_string());
|
||||
}
|
||||
}
|
||||
// Prefix fallback (e.g. "en-US" → "en")
|
||||
let prefix = lower.split('-').next().unwrap_or("");
|
||||
if !prefix.is_empty() {
|
||||
for key in available.keys() {
|
||||
if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------- Free-function helpers ----------
|
||||
|
||||
/// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text.
|
||||
/// Returns just the percentage when no reset time is available.
|
||||
pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> String {
|
||||
let pct = format!("{:.0}%", window.utilization);
|
||||
let cd = format_countdown(window.resets_at, strings);
|
||||
if cd.is_empty() {
|
||||
pct
|
||||
} else {
|
||||
format!("{pct} \u{00b7} {cd}")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
|
||||
let Some(reset) = resets_at else {
|
||||
return String::new();
|
||||
};
|
||||
let remaining = match reset.duration_since(SystemTime::now()) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return strings.now.clone(),
|
||||
};
|
||||
format_countdown_secs(remaining.as_secs(), strings)
|
||||
}
|
||||
|
||||
fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String {
|
||||
let days = total_secs / 86_400;
|
||||
let hours = total_secs / 3_600;
|
||||
let mins = total_secs / 60;
|
||||
if days >= 1 {
|
||||
format!("{days}{}", strings.day_suffix)
|
||||
} else if hours >= 1 {
|
||||
format!("{hours}{}", strings.hour_suffix)
|
||||
} else if mins >= 1 {
|
||||
format!("{mins}{}", strings.minute_suffix)
|
||||
} else {
|
||||
format!("{total_secs}{}", strings.second_suffix)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long before `format_window`'s string would change.
|
||||
/// Used by the countdown timer to refresh exactly when needed.
|
||||
pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Duration> {
|
||||
let reset = resets_at?;
|
||||
let remaining = reset.duration_since(SystemTime::now()).ok()?;
|
||||
let secs = remaining.as_secs();
|
||||
let bucket_start = if secs / 86_400 >= 1 {
|
||||
(secs / 86_400) * 86_400
|
||||
} else if secs / 3_600 >= 1 {
|
||||
(secs / 3_600) * 3_600
|
||||
} else if secs / 60 >= 1 {
|
||||
(secs / 60) * 60
|
||||
} else {
|
||||
secs
|
||||
};
|
||||
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
|
||||
}
|
||||
Reference in new issue
Block a user