feat: initial port of claude-code-usage-monitor as a floating bubble

Windows-only floating, draggable circular bubble showing Claude Code
and Codex usage. Derivative of CodeZeno/Claude-Code-Usage-Monitor (MIT),
relicensed under Apache 2.0 with upstream attribution in NOTICE.

- Ported verbatim: poller, updater, tray_icon, theme, localization,
  diagnose, models (~2,700 LOC)
- Original: bubble (circular layered window, drag-anywhere via
  WM_NCHITTEST+HTCAPTION, snap-to-edge, Ctrl+Wheel resize, auto-hide
  on fullscreen), panel (expanded 5h/7d view), app (orchestrator,
  single-instance mutex, polling thread, context menu, dual-bubble
  lifecycle), settings (settings.json persistence)
- Cargo.toml features cover Win32 GDI, HiDpi, Registry, Threading,
  Shell, WindowsAndMessaging, and KeyboardAndMouse
This commit is contained in:
2026-05-15 21:27:31 +07:00
commit c0f3e3f860
42 changed files with 7046 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::bubble::DEFAULT_BUBBLE_SIZE;
use crate::tray_icon::TrayIconKind;
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
const SETTINGS_FILE: &str = "settings.json";
pub const POLL_1_MIN: u32 = 60_000;
pub const POLL_5_MIN: u32 = 5 * 60_000;
pub const POLL_15_MIN: u32 = 15 * 60_000;
pub const POLL_1_HOUR: u32 = 60 * 60_000;
fn default_show_claude() -> bool {
true
}
fn default_show_codex() -> bool {
false
}
fn default_widget_visible() -> bool {
true
}
fn default_bubble_size() -> i32 {
DEFAULT_BUBBLE_SIZE
}
fn default_poll_interval_ms() -> u32 {
POLL_5_MIN
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
}
impl BubblePositions {
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
match model {
TrayIconKind::Claude => self.claude,
TrayIconKind::Codex => self.codex,
}
}
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
match model {
TrayIconKind::Claude => self.claude = Some(pos),
TrayIconKind::Codex => self.codex = Some(pos),
}
}
pub fn reset(&mut self, model: TrayIconKind) {
match model {
TrayIconKind::Claude => self.claude = None,
TrayIconKind::Codex => self.codex = None,
}
}
pub fn reset_all(&mut self) {
self.claude = None;
self.codex = None;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_show_claude")]
pub show_claude_code: bool,
#[serde(default = "default_show_codex")]
pub show_codex: bool,
#[serde(default)]
pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")]
pub bubble_size_logical: i32,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u32,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub last_update_check_unix: Option<u64>,
#[serde(default = "default_widget_visible")]
pub widget_visible: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
show_claude_code: default_show_claude(),
show_codex: default_show_codex(),
bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(),
language: None,
last_update_check_unix: None,
widget_visible: default_widget_visible(),
}
}
}
pub fn settings_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
}
pub fn settings_path() -> PathBuf {
settings_dir()
.unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME))
.join(SETTINGS_FILE)
}
pub fn load() -> Settings {
let path = settings_path();
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Settings::default(),
};
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
// At least one model must be visible. Otherwise the app has nothing to show.
if !settings.show_claude_code && !settings.show_codex {
settings.show_claude_code = true;
}
// Clamp bubble size to safe range in case settings.json was hand-edited.
settings.bubble_size_logical = settings
.bubble_size_logical
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
settings
}
pub fn save(settings: &Settings) {
let path = settings_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(json) = serde_json::to_string_pretty(settings) else {
return;
};
// Atomic write: tmp then rename. Falls back to direct write on rename failure.
let tmp_path = path.with_extension("json.tmp");
if std::fs::write(&tmp_path, &json).is_ok() && std::fs::rename(&tmp_path, &path).is_ok() {
return;
}
let _ = std::fs::write(&path, json);
}