mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 20:19:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2b14fc03 | ||
|
|
51889d3c39 | ||
|
|
6661a7a10b | ||
|
|
6cafffc883 | ||
|
|
b58811bfe0 | ||
|
|
e50aa3522a | ||
|
|
a3f1323154 | ||
|
|
081a70a537 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.15"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.1.15"
|
||||
version = "0.3.3"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
|
||||
@@ -28,7 +28,9 @@ self-updater are all written from scratch against the same public APIs
|
||||
Codex usage as a percentage and a colored progress ring
|
||||
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
||||
released
|
||||
- Resize with `Ctrl + MouseWheel` on the bubble (32–128 pixels)
|
||||
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
|
||||
**Make smaller / Make larger / Reset size** from the right-click menu
|
||||
(140–360 logical pixels)
|
||||
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
||||
bars plus reset countdowns
|
||||
- Right-click for refresh, displayed models, update frequency, language,
|
||||
@@ -84,10 +86,11 @@ release to snap to the nearest edge if you let go close to one.
|
||||
|
||||
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
||||
- **Right-click** for refresh, models, refresh frequency, language, "Start
|
||||
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
|
||||
manual "Check for updates", exit
|
||||
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
|
||||
Weekly), manual "Check for updates", exit
|
||||
- **Drag** anywhere — it floats on top of all other windows
|
||||
- **Ctrl + MouseWheel** on the bubble to resize it
|
||||
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
|
||||
menu, to resize it
|
||||
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||
right-click opens the same menu
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# UI/UX Improvement Plan
|
||||
|
||||
## Context
|
||||
- Product: native Windows floating usage bubble for Claude Code/Codex.
|
||||
- Current UI stack: Win32 popup/layered windows, tiny-skia drawing, GDI text, Shell tray icons.
|
||||
- Current baseline: `cargo check` passes on 2026-05-23.
|
||||
- Primary files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, `src/tray/*`, `src/usage_color.rs`, `src/i18n/locales/*.toml`.
|
||||
|
||||
## Phase 1 - Accessibility And Status Clarity
|
||||
- Status: Partially Complete
|
||||
- Priority: High
|
||||
- Files: `src/usage_color.rs`, `src/bubble.rs`, `src/panel.rs`, `src/tray/mod.rs`, locale TOMLs.
|
||||
- Improve non-color status cues for normal/warning/critical/auth/error states.
|
||||
- Add richer tray tooltips: model, 5h percent/countdown, 7d percent/countdown, current state.
|
||||
- Add localized strings for warning/critical labels and unavailable/auth states.
|
||||
- Keep usage colors centralized in `usage_color.rs`; avoid per-surface color drift.
|
||||
- Validation: contrast check for light/dark colors, manual tray tooltip check, `cargo check`.
|
||||
- Completed 2026-05-23: richer tray tooltip now includes model, 5h, 7d, and left-click hint.
|
||||
- Completed 2026-05-23: tray tooltip uses shorter localized tray hint text to reduce truncation risk.
|
||||
|
||||
## Phase 2 - Discoverability And Native Controls
|
||||
- Status: Partially Complete
|
||||
- Priority: High
|
||||
- Files: `src/app.rs`, `src/bubble.rs`, locale TOMLs.
|
||||
- Add menu items for common hidden actions: resize smaller/larger, reset size, show details.
|
||||
- Add a short localized "Help" or "Controls" submenu listing drag, click, right-click, Ctrl+wheel.
|
||||
- Make resize available through menu commands, not only Ctrl+MouseWheel.
|
||||
- Review context-menu grouping so status/update/model/settings actions scan as separate groups.
|
||||
- Validation: keyboard-access menu traversal, menu command behavior, persisted settings.
|
||||
- Completed 2026-05-23: added localized Controls submenu with resize actions and disabled help rows.
|
||||
- Completed 2026-05-23: disabled resize commands when they would no-op and unified menu/wheel resize through shared bubble size.
|
||||
|
||||
## Phase 3 - Bubble Legibility And Interaction Robustness
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/bubble.rs`, optional extracted bubble modules.
|
||||
- Improve layout for smallest sizes: reserve stable text bounds, handle `100%`, placeholder, `!`, and long countdowns.
|
||||
- Consider minimum size/shape copy update because code uses 140-360 logical width while README mentions 32-128 pixels.
|
||||
- Add drag threshold and click behavior review around `WM_EXITSIZEMOVE` to reduce accidental panel opens.
|
||||
- Add optional pulse reduction path if Windows animation/reduced-motion preference is available.
|
||||
- Validation: manual checks at min/default/max size, 100/125/150/200% DPI, both models enabled.
|
||||
|
||||
## Phase 4 - Expanded Panel Redesign
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/panel.rs`, locale TOMLs, maybe `src/app.rs`.
|
||||
- Replace fixed 280x120 assumptions with measured or wider adaptive layout.
|
||||
- Make rows self-explanatory: model header, 5h and 7d labels, percent plus reset countdown.
|
||||
- Add explicit error/auth/loading state rendering instead of only symbols/placeholders.
|
||||
- Improve panel placement near screen edges and multi-monitor boundaries.
|
||||
- Consider extracting panel layout/painting into smaller modules before behavior changes.
|
||||
- Validation: all locales, long countdown text, light/dark theme, focus-loss close behavior.
|
||||
|
||||
## Phase 5 - Tray And Notification Polish
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/tray/mod.rs`, `src/tray/badge.rs`, `src/app.rs`.
|
||||
- Make tray icon state readable without exact color distinction: tooltip carries exact data, icon bands remain coarse.
|
||||
- Review notification throttling and text for threshold crossings.
|
||||
- Ensure tray left-click/right-click behavior matches Windows notification-area conventions.
|
||||
- Add manual test matrix for one-provider and two-provider modes.
|
||||
- Validation: tray add/modify/delete, balloon messages, no stale icons after exit/restart.
|
||||
|
||||
## Phase 6 - Structure And Verification
|
||||
- Status: Planned
|
||||
- Priority: Medium
|
||||
- Files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, docs if behavior changes.
|
||||
- Split only where it reduces real risk: bubble layout/rendering/interaction and panel layout/rendering first.
|
||||
- Keep public behavior stable while extracting.
|
||||
- Add unit tests for pure functions where practical: color bands, size clamps, layout math, countdown formatting.
|
||||
- Run `cargo check`; run `cargo test` if tests are added.
|
||||
- Update README/docs after behavior changes, especially controls and size range.
|
||||
- Completed 2026-05-23: added locale schema tests covering embedded locale parsing and Controls/tray strings.
|
||||
|
||||
## Success Criteria
|
||||
- Bubble and panel remain readable at min/default/max sizes and common DPI scales.
|
||||
- Warning/critical/auth/error states are understandable without relying only on color.
|
||||
- Hidden interactions have menu alternatives or discoverable help text.
|
||||
- Panel handles all existing locales without clipping core data.
|
||||
- Tray tooltip and notifications communicate exact state.
|
||||
- Source still compiles; new pure behavior has focused tests where feasible.
|
||||
|
||||
## Risks
|
||||
- Native Win32 UI changes require manual Windows runtime verification; screenshots/tests are limited.
|
||||
- `src/bubble.rs` and `src/app.rs` are large and coupled; extract before broad changes when touching multiple concerns.
|
||||
- Adaptive text/layout can regress small-size readability if not verified at 140 logical width.
|
||||
|
||||
## Unresolved Questions
|
||||
- Should the bubble stay stadium-shaped, or should compact circular mode return as an option?
|
||||
- Should menu help be always present, or only shown on first run/first right-click?
|
||||
- Should reduced-motion preference disable only pulse, or all nonessential animation?
|
||||
+144
-28
@@ -70,6 +70,9 @@ const IDM_START_WITH_WINDOWS: u16 = 30;
|
||||
const IDM_RESET_POSITION: u16 = 31;
|
||||
const IDM_VERSION_ACTION: u16 = 32;
|
||||
const IDM_RESTART: u16 = 33;
|
||||
const IDM_SIZE_SMALLER: u16 = 34;
|
||||
const IDM_SIZE_LARGER: u16 = 35;
|
||||
const IDM_RESET_SIZE: u16 = 36;
|
||||
const IDM_LANG_SYSTEM: u16 = 40;
|
||||
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
|
||||
// clear of it (and any future tray ids in the 5x band).
|
||||
@@ -340,8 +343,10 @@ fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
|
||||
position: settings.bubble_positions.get(kind),
|
||||
session_pct: None,
|
||||
session_text: placeholder.clone(),
|
||||
session_resets_at: None,
|
||||
weekly_pct: None,
|
||||
weekly_text: placeholder,
|
||||
weekly_resets_at: None,
|
||||
is_dark,
|
||||
});
|
||||
if hwnd != HWND::default() {
|
||||
@@ -401,7 +406,7 @@ fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
||||
}
|
||||
|
||||
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
|
||||
update_settings(|s| s.settings.bubble_size_logical = size_logical);
|
||||
set_bubble_size(size_logical);
|
||||
}
|
||||
|
||||
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
@@ -418,13 +423,14 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
IDM_START_WITH_WINDOWS => toggle_startup(),
|
||||
IDM_RESET_POSITION => reset_positions(),
|
||||
IDM_VERSION_ACTION => version_action(),
|
||||
IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL),
|
||||
IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL),
|
||||
IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE),
|
||||
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
|
||||
IDM_UPDATE_AUTO_HOURLY => {
|
||||
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
||||
}
|
||||
IDM_UPDATE_AUTO_DAILY => {
|
||||
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
|
||||
}
|
||||
IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)),
|
||||
IDM_UPDATE_AUTO_WEEKLY => {
|
||||
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
||||
}
|
||||
@@ -636,12 +642,16 @@ fn propagate_to_ui() {
|
||||
let weekly_text = entry
|
||||
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
|
||||
.unwrap_or_default();
|
||||
let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at);
|
||||
let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at);
|
||||
bubble::update_data(
|
||||
hwnd.to_hwnd(),
|
||||
session_pct,
|
||||
session_text,
|
||||
session_resets_at,
|
||||
weekly_pct,
|
||||
weekly_text,
|
||||
weekly_resets_at,
|
||||
);
|
||||
}
|
||||
refresh_tray_icons_with(&snap);
|
||||
@@ -759,14 +769,7 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: format!(
|
||||
"{} {}: {} | {}: {}",
|
||||
snap.i18n_strings.claude_label,
|
||||
snap.i18n_strings.session_window,
|
||||
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
||||
snap.i18n_strings.weekly_window,
|
||||
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
||||
),
|
||||
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
|
||||
});
|
||||
}
|
||||
if snap.settings.show_codex {
|
||||
@@ -778,19 +781,27 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: format!(
|
||||
"{} {}: {} | {}: {}",
|
||||
snap.i18n_strings.chatgpt_label,
|
||||
snap.i18n_strings.session_window,
|
||||
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
||||
snap.i18n_strings.weekly_window,
|
||||
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
||||
),
|
||||
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
|
||||
});
|
||||
}
|
||||
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
||||
}
|
||||
|
||||
fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String {
|
||||
let session = entry
|
||||
.map(|e| e.primary_text.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("...");
|
||||
let weekly = entry
|
||||
.map(|e| e.secondary_text.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("...");
|
||||
format!(
|
||||
"{label}\n{}: {session}\n{}: {weekly}\n{}",
|
||||
strings.session_window, strings.weekly_window, strings.tray_left_click
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_tray_action(action: TrayAction) {
|
||||
match action {
|
||||
TrayAction::None => {}
|
||||
@@ -926,6 +937,7 @@ struct ContextMenuSnapshot {
|
||||
widget_visible: bool,
|
||||
install_channel: InstallChannel,
|
||||
update_status: UpdateStatus,
|
||||
bubble_size_logical: i32,
|
||||
}
|
||||
|
||||
fn show_context_menu(owner_hwnd: HWND) {
|
||||
@@ -945,6 +957,7 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
widget_visible: s.settings.widget_visible,
|
||||
install_channel: s.install_channel,
|
||||
update_status: s.update_status,
|
||||
bubble_size_logical: s.settings.bubble_size_logical,
|
||||
},
|
||||
None => return,
|
||||
};
|
||||
@@ -986,13 +999,21 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
models,
|
||||
IDM_MODEL_CLAUDE,
|
||||
&snap.strings.claude_label,
|
||||
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.show_claude {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
models,
|
||||
IDM_MODEL_CHATGPT,
|
||||
&snap.strings.chatgpt_label,
|
||||
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.show_chatgpt {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_submenu(menu, models, &snap.strings.models);
|
||||
|
||||
@@ -1005,7 +1026,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
settings_menu,
|
||||
IDM_START_WITH_WINDOWS,
|
||||
&snap.strings.start_with_windows,
|
||||
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if is_startup_enabled() {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
settings_menu,
|
||||
@@ -1024,7 +1049,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
lang,
|
||||
IDM_LANG_SYSTEM,
|
||||
&snap.strings.system_default,
|
||||
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.language_override.is_none() {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
for (i, (code, name)) in snap.available.iter().enumerate() {
|
||||
let id = IDM_LANG_BASE + i as u16;
|
||||
@@ -1052,7 +1081,12 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
};
|
||||
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
|
||||
append_item(
|
||||
settings_menu,
|
||||
IDM_VERSION_ACTION,
|
||||
&version_label,
|
||||
version_flags,
|
||||
);
|
||||
|
||||
let Ok(auto_update) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(auto_update) failed");
|
||||
@@ -1088,11 +1122,58 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
||||
append_submenu(menu, settings_menu, &snap.strings.settings);
|
||||
|
||||
let Ok(controls) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(controls) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
controls,
|
||||
IDM_SIZE_SMALLER,
|
||||
&snap.strings.size_smaller,
|
||||
if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE {
|
||||
MF_GRAYED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
controls,
|
||||
IDM_SIZE_LARGER,
|
||||
&snap.strings.size_larger,
|
||||
if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE {
|
||||
MF_GRAYED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
controls,
|
||||
IDM_RESET_SIZE,
|
||||
&snap.strings.reset_size,
|
||||
if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE {
|
||||
MF_GRAYED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED);
|
||||
append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED);
|
||||
append_submenu(menu, controls, &snap.strings.controls);
|
||||
|
||||
append_item(
|
||||
menu,
|
||||
tray::IDM_TOGGLE_WIDGET,
|
||||
&snap.strings.show_widget,
|
||||
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
||||
if snap.widget_visible {
|
||||
MF_CHECKED
|
||||
} else {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
||||
@@ -1116,7 +1197,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
|
||||
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
||||
let w = os::to_utf16_nul(label);
|
||||
unsafe {
|
||||
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
|
||||
let _ = AppendMenuW(
|
||||
menu,
|
||||
MF_POPUP,
|
||||
submenu.0 as usize,
|
||||
PCWSTR::from_raw(w.as_ptr()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,7 +1267,9 @@ fn toggle_model(model: ProviderId) {
|
||||
ProviderId::Claude => settings.show_claude_code,
|
||||
ProviderId::ChatGpt => settings.show_codex,
|
||||
};
|
||||
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
|
||||
let existing = lock_state()
|
||||
.as_ref()
|
||||
.and_then(|s| s.bubbles.get(&model).copied());
|
||||
match (want, existing) {
|
||||
(true, None) => spawn_bubble(model, &settings, is_dark),
|
||||
(false, Some(h)) => {
|
||||
@@ -1244,6 +1332,34 @@ fn reset_positions() {
|
||||
spawn_poll_thread();
|
||||
}
|
||||
|
||||
fn resize_bubbles(delta: i32) {
|
||||
let current = lock_state()
|
||||
.as_ref()
|
||||
.map(|s| s.settings.bubble_size_logical)
|
||||
.unwrap_or(bubble::DEFAULT_BUBBLE_SIZE);
|
||||
set_bubble_size(current + delta);
|
||||
}
|
||||
|
||||
fn set_bubble_size(size_logical: i32) {
|
||||
let (hwnds, snap) = {
|
||||
let mut s = lock_state();
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE);
|
||||
if new_size == s.settings.bubble_size_logical {
|
||||
return;
|
||||
}
|
||||
s.settings.bubble_size_logical = new_size;
|
||||
let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::<Vec<_>>();
|
||||
(hwnds, s.settings.clone())
|
||||
};
|
||||
settings::save(&snap);
|
||||
for hwnd in hwnds {
|
||||
bubble::set_size_logical(hwnd, snap.bubble_size_logical);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_language(_dummy: Option<()>) {
|
||||
update_settings(|s| {
|
||||
s.i18n.set_active(None);
|
||||
|
||||
+267
-58
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
|
||||
// The shape is a stadium (rounded-rect with corner_radius = height/2). The left
|
||||
// half is the "head" — a stroked progress ring around the 5h percentage glyph.
|
||||
// The right half is the "tail" — small "7d" label, thin progress bar, countdown.
|
||||
// half is the "head" — usage and remaining-time rings around the 5h percentage
|
||||
// glyph. The right half is the "tail" — weekly usage and remaining-time bars.
|
||||
//
|
||||
// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc)
|
||||
// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB;
|
||||
@@ -14,6 +14,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
|
||||
use windows::core::PCWSTR;
|
||||
@@ -32,7 +33,9 @@ use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
|
||||
const TIMER_FULLSCREEN_CHECK: usize = 5;
|
||||
const TIMER_PULSE: usize = 6;
|
||||
const TIMER_TIME_PROGRESS: usize = 7;
|
||||
const PULSE_INTERVAL_MS: u32 = 80;
|
||||
const TIME_PROGRESS_INTERVAL_MS: u32 = 60_000;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
// ---------- Public types & API ----------
|
||||
@@ -44,7 +47,7 @@ use crate::usage::ProviderId;
|
||||
pub const MIN_BUBBLE_SIZE: i32 = 140;
|
||||
pub const MAX_BUBBLE_SIZE: i32 = 360;
|
||||
pub const DEFAULT_BUBBLE_SIZE: i32 = 200;
|
||||
const RESIZE_STEP: i32 = 20;
|
||||
pub const RESIZE_STEP_LOGICAL: i32 = 20;
|
||||
const SNAP_ZONE_LOGICAL: i32 = 12;
|
||||
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
|
||||
const CORNER_INSET_LOGICAL: i32 = 12;
|
||||
@@ -52,6 +55,8 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
|
||||
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const FULLSCREEN_POLL_MS: u32 = 1500;
|
||||
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
|
||||
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
|
||||
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
|
||||
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
|
||||
@@ -72,8 +77,10 @@ pub struct BubbleConfig {
|
||||
pub position: Option<(i32, i32)>,
|
||||
pub session_pct: Option<f64>,
|
||||
pub session_text: String,
|
||||
pub session_resets_at: Option<SystemTime>,
|
||||
pub weekly_pct: Option<f64>,
|
||||
pub weekly_text: String,
|
||||
pub weekly_resets_at: Option<SystemTime>,
|
||||
pub is_dark: bool,
|
||||
}
|
||||
|
||||
@@ -82,6 +89,31 @@ fn bubble_height_logical(width_logical: i32) -> i32 {
|
||||
((width_logical * den) / num).max(20)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum UsageWindowKind {
|
||||
Primary,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 {
|
||||
// Claude exposes 5h/7d directly. Codex exposes primary/secondary fields;
|
||||
// the product maps those to the same short/long windows in the compact UI.
|
||||
match (model, window) {
|
||||
(ProviderId::Claude, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
|
||||
(ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
|
||||
(ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
|
||||
(ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining_fraction(resets_at: Option<SystemTime>, duration_secs: u64) -> Option<f32> {
|
||||
let reset = resets_at?;
|
||||
let remaining = reset
|
||||
.duration_since(SystemTime::now())
|
||||
.unwrap_or_else(|_| Duration::from_secs(0));
|
||||
Some((remaining.as_secs_f64() / duration_secs as f64).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// Owner-supplied event callbacks. The bubble window proc is a leaf — it
|
||||
/// doesn't know about `app`. The owner installs these once at startup so the
|
||||
/// proc can dispatch UI events back without an upward `crate::app::` reach.
|
||||
@@ -137,9 +169,7 @@ pub fn register_class() {
|
||||
/// message-loop dispatch.
|
||||
pub fn create(config: BubbleConfig) -> HWND {
|
||||
register_class();
|
||||
let initial_size_logical = config
|
||||
.size_logical
|
||||
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
let initial_size_logical = config.size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
let dpi_for_create = crate::os::dpi::for_system();
|
||||
let width_px = scale_to_dpi(initial_size_logical, dpi_for_create);
|
||||
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create);
|
||||
@@ -205,14 +235,17 @@ pub fn create(config: BubbleConfig) -> HWND {
|
||||
dpi,
|
||||
session_pct: config.session_pct,
|
||||
session_text: config.session_text,
|
||||
session_resets_at: config.session_resets_at,
|
||||
weekly_pct: config.weekly_pct,
|
||||
weekly_text: config.weekly_text,
|
||||
weekly_resets_at: config.weekly_resets_at,
|
||||
is_dark: config.is_dark,
|
||||
drag_start_pos: None,
|
||||
hidden_by_fullscreen: false,
|
||||
user_hidden: false,
|
||||
pulse_phase: 0,
|
||||
pulse_timer_armed: false,
|
||||
time_progress_timer_armed: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -240,6 +273,7 @@ pub fn destroy(hwnd: HWND) {
|
||||
unsafe {
|
||||
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
|
||||
let _ = KillTimer(hwnd, TIMER_PULSE);
|
||||
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
@@ -275,8 +309,10 @@ pub fn update_data(
|
||||
hwnd: HWND,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
session_resets_at: Option<SystemTime>,
|
||||
weekly_pct: Option<f64>,
|
||||
weekly_text: String,
|
||||
weekly_resets_at: Option<SystemTime>,
|
||||
) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
@@ -285,10 +321,13 @@ pub fn update_data(
|
||||
};
|
||||
b.session_pct = session_pct;
|
||||
b.session_text = session_text;
|
||||
b.session_resets_at = session_resets_at;
|
||||
b.weekly_pct = weekly_pct;
|
||||
b.weekly_text = weekly_text;
|
||||
b.weekly_resets_at = weekly_resets_at;
|
||||
}
|
||||
sync_pulse_timer(hwnd);
|
||||
sync_time_progress_timer(hwnd);
|
||||
render(hwnd);
|
||||
}
|
||||
|
||||
@@ -322,6 +361,32 @@ fn sync_pulse_timer(hwnd: HWND) {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_time_progress_timer(hwnd: HWND) {
|
||||
let (should_be_armed, currently_armed) = {
|
||||
let bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
(
|
||||
b.session_resets_at.is_some() || b.weekly_resets_at.is_some(),
|
||||
b.time_progress_timer_armed,
|
||||
)
|
||||
};
|
||||
if should_be_armed == currently_armed {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
if should_be_armed {
|
||||
SetTimer(hwnd, TIMER_TIME_PROGRESS, TIME_PROGRESS_INTERVAL_MS, None);
|
||||
} else {
|
||||
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
|
||||
}
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||||
b.time_progress_timer_armed = should_be_armed;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
@@ -366,9 +431,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
}
|
||||
|
||||
pub fn model(hwnd: HWND) -> Option<ProviderId> {
|
||||
lock_bubbles()
|
||||
.get(&(hwnd.0 as isize))
|
||||
.map(|b| b.model)
|
||||
lock_bubbles().get(&(hwnd.0 as isize)).map(|b| b.model)
|
||||
}
|
||||
|
||||
pub fn size_logical(hwnd: HWND) -> Option<i32> {
|
||||
@@ -385,8 +448,10 @@ struct BubbleState {
|
||||
dpi: u32,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
session_resets_at: Option<SystemTime>,
|
||||
weekly_pct: Option<f64>,
|
||||
weekly_text: String,
|
||||
weekly_resets_at: Option<SystemTime>,
|
||||
is_dark: bool,
|
||||
drag_start_pos: Option<(i32, i32)>,
|
||||
hidden_by_fullscreen: bool,
|
||||
@@ -396,6 +461,8 @@ struct BubbleState {
|
||||
pulse_phase: u32,
|
||||
/// Whether TIMER_PULSE is currently armed for this bubble.
|
||||
pulse_timer_armed: bool,
|
||||
/// Whether TIMER_TIME_PROGRESS is armed to keep reset-time visuals current.
|
||||
time_progress_timer_armed: bool,
|
||||
}
|
||||
|
||||
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
|
||||
@@ -468,7 +535,11 @@ unsafe extern "system" fn wnd_proc(
|
||||
const MK_CONTROL: u32 = 0x0008;
|
||||
if modifiers & MK_CONTROL != 0 {
|
||||
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
|
||||
let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP };
|
||||
let step = if delta > 0 {
|
||||
RESIZE_STEP_LOGICAL
|
||||
} else {
|
||||
-RESIZE_STEP_LOGICAL
|
||||
};
|
||||
resize_step(hwnd, step);
|
||||
LRESULT(0)
|
||||
} else {
|
||||
@@ -505,6 +576,7 @@ unsafe extern "system" fn wnd_proc(
|
||||
}
|
||||
render(hwnd);
|
||||
}
|
||||
w if w == TIMER_TIME_PROGRESS => render(hwnd),
|
||||
_ => {}
|
||||
}
|
||||
LRESULT(0)
|
||||
@@ -583,12 +655,19 @@ fn lparam_to_point(lparam: LPARAM) -> POINT {
|
||||
// ---------- Resize / snap ----------
|
||||
|
||||
fn resize_step(hwnd: HWND, delta: i32) {
|
||||
let Some(current) = size_logical(hwnd) else {
|
||||
return;
|
||||
};
|
||||
set_size_logical(hwnd, current + delta);
|
||||
}
|
||||
|
||||
pub fn set_size_logical(hwnd: HWND, size_logical: i32) {
|
||||
let (new_logical, dpi) = {
|
||||
let mut bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
let new_logical = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
if new_logical == b.size_logical {
|
||||
return;
|
||||
}
|
||||
@@ -954,7 +1033,7 @@ const COUNTDOWN_TEMPLATE: &str = "999시간";
|
||||
///
|
||||
/// The outline is a stadium (rounded rect with `corner_radius = canvas_h / 2`).
|
||||
/// The left `head_diameter × canvas_h` square holds the 5h progress ring + big
|
||||
/// percent glyph. The rest is the tail: 7d label, thin bar, countdown.
|
||||
/// percent glyph. The rest is the tail: weekly usage lane + reset-time lane.
|
||||
struct BubbleLayout {
|
||||
canvas_w: i32,
|
||||
canvas_h: i32,
|
||||
@@ -964,11 +1043,14 @@ struct BubbleLayout {
|
||||
ring_cy: f32,
|
||||
ring_radius: f32,
|
||||
ring_stroke_w: f32,
|
||||
time_ring_radius: f32,
|
||||
time_ring_stroke_w: f32,
|
||||
head_label_rect: RECT,
|
||||
head_pct_rect: RECT,
|
||||
tail_label_rect: RECT,
|
||||
tail_bar_rect: RECT,
|
||||
tail_countdown_rect: RECT,
|
||||
tail_usage_pct_rect: RECT,
|
||||
tail_usage_bar_rect: RECT,
|
||||
tail_time_text_rect: RECT,
|
||||
tail_time_bar_rect: RECT,
|
||||
big_font_px: i32,
|
||||
small_font_px: i32,
|
||||
main_font_px: i32,
|
||||
@@ -987,6 +1069,9 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
// inside the head padding. ring_radius is the centerline radius.
|
||||
let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32);
|
||||
let ring_radius = ring_outer - ring_stroke_w / 2.0;
|
||||
let time_ring_stroke_w = scale_to_dpi(2, dpi).clamp(1, 3) as f32;
|
||||
let time_ring_radius =
|
||||
(ring_radius - ring_stroke_w - scale_to_dpi(3, dpi) as f32).max(time_ring_stroke_w);
|
||||
|
||||
let big_font_px = (head_diameter * 26 / 100).max(scale_to_dpi(11, dpi));
|
||||
let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi));
|
||||
@@ -1015,17 +1100,39 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
let pad = scale_to_dpi(6, dpi);
|
||||
|
||||
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px);
|
||||
let label_w = measure_text_w(mem_dc, "7d", small_font_px);
|
||||
let pct_reserve_w = measure_text_w(mem_dc, "100%", small_font_px) + scale_to_dpi(2, dpi);
|
||||
|
||||
let tail_label_left = tail_left + pad;
|
||||
let tail_label_right = tail_label_left + label_w;
|
||||
let tail_countdown_right = tail_right;
|
||||
let tail_countdown_left = tail_countdown_right - countdown_w;
|
||||
let tail_bar_left = tail_label_right + pad;
|
||||
let tail_bar_right =
|
||||
(tail_countdown_left - pad).max(tail_bar_left + scale_to_dpi(20, dpi));
|
||||
let tail_bar_h = scale_to_dpi(5, dpi);
|
||||
let tail_bar_top = (height_px - tail_bar_h) / 2;
|
||||
let usage_bar_h = (height_px * 9 / 100).clamp(scale_to_dpi(5, dpi), scale_to_dpi(12, dpi));
|
||||
let time_bar_h = (height_px * 5 / 100).clamp(scale_to_dpi(3, dpi), scale_to_dpi(7, dpi));
|
||||
let lane_gap = scale_to_dpi(5, dpi);
|
||||
let lanes_h = usage_bar_h + lane_gap + time_bar_h;
|
||||
let usage_bar_top = (height_px - lanes_h) / 2;
|
||||
let time_bar_top = usage_bar_top + usage_bar_h + lane_gap;
|
||||
let time_text_h = main_font_px + scale_to_dpi(2, dpi);
|
||||
let usage_pct_h = small_font_px + scale_to_dpi(2, dpi);
|
||||
|
||||
let content_left = tail_left + pad;
|
||||
let content_right = tail_right;
|
||||
let content_w = (content_right - content_left).max(0);
|
||||
let time_text_w = countdown_w.min(content_w);
|
||||
let time_text_left = content_right - time_text_w;
|
||||
let time_bar_min = scale_to_dpi(8, dpi);
|
||||
let time_bar_right = (time_text_left - pad).max(content_left + time_bar_min);
|
||||
let time_bar_left = content_left.min(time_bar_right);
|
||||
|
||||
let usage_bar_min = scale_to_dpi(8, dpi);
|
||||
let show_usage_pct = content_w >= pct_reserve_w + pad + usage_bar_min;
|
||||
let usage_pct_right = if show_usage_pct {
|
||||
content_left + pct_reserve_w
|
||||
} else {
|
||||
content_left
|
||||
};
|
||||
let usage_bar_left = if show_usage_pct {
|
||||
usage_pct_right + pad
|
||||
} else {
|
||||
content_left
|
||||
};
|
||||
let usage_bar_right = content_right.max(usage_bar_left + usage_bar_min);
|
||||
|
||||
BubbleLayout {
|
||||
canvas_w: width_px,
|
||||
@@ -1036,25 +1143,33 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
ring_cy,
|
||||
ring_radius,
|
||||
ring_stroke_w,
|
||||
time_ring_radius,
|
||||
time_ring_stroke_w,
|
||||
head_label_rect,
|
||||
head_pct_rect,
|
||||
tail_label_rect: RECT {
|
||||
left: tail_label_left,
|
||||
top: 0,
|
||||
right: tail_label_right,
|
||||
bottom: height_px,
|
||||
tail_usage_pct_rect: RECT {
|
||||
left: content_left,
|
||||
top: usage_bar_top + (usage_bar_h - usage_pct_h) / 2,
|
||||
right: usage_pct_right,
|
||||
bottom: usage_bar_top + (usage_bar_h - usage_pct_h) / 2 + usage_pct_h,
|
||||
},
|
||||
tail_bar_rect: RECT {
|
||||
left: tail_bar_left,
|
||||
top: tail_bar_top,
|
||||
right: tail_bar_right,
|
||||
bottom: tail_bar_top + tail_bar_h,
|
||||
tail_usage_bar_rect: RECT {
|
||||
left: usage_bar_left,
|
||||
top: usage_bar_top,
|
||||
right: usage_bar_right,
|
||||
bottom: usage_bar_top + usage_bar_h,
|
||||
},
|
||||
tail_countdown_rect: RECT {
|
||||
left: tail_countdown_left,
|
||||
top: 0,
|
||||
right: tail_countdown_right,
|
||||
bottom: height_px,
|
||||
tail_time_text_rect: RECT {
|
||||
left: time_text_left,
|
||||
top: time_bar_top + (time_bar_h - time_text_h) / 2,
|
||||
right: content_right,
|
||||
bottom: time_bar_top + (time_bar_h - time_text_h) / 2 + time_text_h,
|
||||
},
|
||||
tail_time_bar_rect: RECT {
|
||||
left: time_bar_left,
|
||||
top: time_bar_top,
|
||||
right: time_bar_right,
|
||||
bottom: time_bar_top + time_bar_h,
|
||||
},
|
||||
big_font_px,
|
||||
small_font_px,
|
||||
@@ -1079,6 +1194,16 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
let time_track = if inputs.is_dark {
|
||||
Color::from_hex("#303030")
|
||||
} else {
|
||||
Color::from_hex("#E0E0E0")
|
||||
};
|
||||
let time_fill = if inputs.is_dark {
|
||||
Color::from_hex("#9A9A9A")
|
||||
} else {
|
||||
Color::from_hex("#777777")
|
||||
};
|
||||
|
||||
// ---- Stadium background ----
|
||||
{
|
||||
@@ -1119,7 +1244,8 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
if let Some(pct) = inputs.session_pct {
|
||||
let sweep = (pct.clamp(0.0, 100.0) / 100.0) as f32;
|
||||
if sweep > 0.0 {
|
||||
let mut color = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct);
|
||||
let mut color =
|
||||
crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct);
|
||||
if pct >= 95.0 {
|
||||
let t = pulse_triangle(inputs.pulse_phase);
|
||||
color = brighten(color, t);
|
||||
@@ -1137,14 +1263,45 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inner ring: true remaining time for the 5h/primary window. This
|
||||
// stays neutral so it reads as time, not another quota alarm.
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(rgb_to_skia(time_track));
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = layout.time_ring_stroke_w;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(layout.ring_cx, layout.ring_cy, layout.time_ring_radius);
|
||||
if let Some(p) = pb.finish() {
|
||||
pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
if let Some(frac) = remaining_fraction(
|
||||
inputs.session_resets_at,
|
||||
window_duration_secs(inputs.model, UsageWindowKind::Primary),
|
||||
) {
|
||||
if frac > 0.0 {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(rgb_to_skia(time_fill));
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = layout.time_ring_stroke_w;
|
||||
stroke.line_cap = LineCap::Round;
|
||||
if let Some(path) =
|
||||
build_arc(layout.ring_cx, layout.ring_cy, layout.time_ring_radius, frac)
|
||||
{
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tail bar (7d) ----
|
||||
// ---- Tail usage bar + reset-time bar ----
|
||||
{
|
||||
let bar_x = layout.tail_bar_rect.left as f32;
|
||||
let bar_y = layout.tail_bar_rect.top as f32;
|
||||
let bar_w = (layout.tail_bar_rect.right - layout.tail_bar_rect.left) as f32;
|
||||
let bar_h = (layout.tail_bar_rect.bottom - layout.tail_bar_rect.top) as f32;
|
||||
let bar_x = layout.tail_usage_bar_rect.left as f32;
|
||||
let bar_y = layout.tail_usage_bar_rect.top as f32;
|
||||
let bar_w = (layout.tail_usage_bar_rect.right - layout.tail_usage_bar_rect.left) as f32;
|
||||
let bar_h = (layout.tail_usage_bar_rect.bottom - layout.tail_usage_bar_rect.top) as f32;
|
||||
let cap = bar_h * 0.5;
|
||||
if bar_w > 0.0 && bar_h > 0.0 {
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, track);
|
||||
@@ -1163,13 +1320,31 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bar_x = layout.tail_time_bar_rect.left as f32;
|
||||
let bar_y = layout.tail_time_bar_rect.top as f32;
|
||||
let bar_w = (layout.tail_time_bar_rect.right - layout.tail_time_bar_rect.left) as f32;
|
||||
let bar_h = (layout.tail_time_bar_rect.bottom - layout.tail_time_bar_rect.top) as f32;
|
||||
let cap = bar_h * 0.5;
|
||||
if bar_w > 0.0 && bar_h > 0.0 {
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, time_track);
|
||||
if let Some(frac) = remaining_fraction(
|
||||
inputs.weekly_resets_at,
|
||||
window_duration_secs(inputs.model, UsageWindowKind::Secondary),
|
||||
) {
|
||||
let fill_w = bar_w * frac;
|
||||
if fill_w > 0.0 {
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, fill_w.min(bar_w), bar_h, cap, time_fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(pixmap)
|
||||
}
|
||||
|
||||
/// Fill a horizontal pill at `(x, y, w, h)` with circular end caps of radius
|
||||
/// `cap`. Used for both the track and the fill of the tail's 7d bar.
|
||||
/// `cap`. Used for both track and fill segments in the tail bars.
|
||||
fn paint_pill(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, cap: f32, color: Color) {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(rgb_to_skia(color));
|
||||
@@ -1273,8 +1448,10 @@ struct PaintInputs {
|
||||
model: ProviderId,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
session_resets_at: Option<SystemTime>,
|
||||
weekly_pct: Option<f64>,
|
||||
weekly_text: String,
|
||||
weekly_resets_at: Option<SystemTime>,
|
||||
is_dark: bool,
|
||||
pulse_phase: u32,
|
||||
}
|
||||
@@ -1292,8 +1469,10 @@ fn render(hwnd: HWND) {
|
||||
model: b.model,
|
||||
session_pct: b.session_pct,
|
||||
session_text: b.session_text.clone(),
|
||||
session_resets_at: b.session_resets_at,
|
||||
weekly_pct: b.weekly_pct,
|
||||
weekly_text: b.weekly_text.clone(),
|
||||
weekly_resets_at: b.weekly_resets_at,
|
||||
is_dark: b.is_dark,
|
||||
pulse_phase: b.pulse_phase,
|
||||
},
|
||||
@@ -1325,8 +1504,8 @@ fn render(hwnd: HWND) {
|
||||
..Default::default()
|
||||
};
|
||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||||
.unwrap_or_default();
|
||||
let dib =
|
||||
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
|
||||
if dib.is_invalid() || bits.is_null() {
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(hwnd, screen_dc);
|
||||
@@ -1413,8 +1592,8 @@ fn brighten(c: Color, t: f64) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
/// Paint the new bubble's text overlay via GDI: small "5h" label + big "%"
|
||||
/// glyph in the head, small "7d" label + countdown on the tail.
|
||||
/// Paint the bubble's text overlay via GDI: primary countdown + big "%"
|
||||
/// glyph in the head, weekly percent + weekly countdown on the tail.
|
||||
fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
let text_color = if inputs.is_dark {
|
||||
Color::from_hex("#EAEAEA")
|
||||
@@ -1436,9 +1615,24 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
|
||||
let prev_font = SelectObject(hdc, small_font);
|
||||
|
||||
// Head: "5h" label (muted, centered horizontally).
|
||||
// Head: 5h countdown text if available, otherwise the static "5h" tag.
|
||||
// The ring already signals "this is the 5h window", so the countdown
|
||||
// is the more useful glanceable info when we have it. Fall back to
|
||||
// "5h" when the localized countdown would overflow the rect (e.g.,
|
||||
// wide CJK strings like "999시간" at the 140-logical minimum width) —
|
||||
// DT_NOCLIP would otherwise leak the glyphs onto the ring stroke.
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
draw_text_in_rect(hdc, &layout.head_label_rect, "5h", DT_CENTER);
|
||||
let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left;
|
||||
let head_label_text: &str = if inputs.session_text.is_empty() {
|
||||
"5h"
|
||||
} else if measure_text_w(hdc, &inputs.session_text, layout.small_font_px)
|
||||
<= head_label_rect_w
|
||||
{
|
||||
inputs.session_text.as_str()
|
||||
} else {
|
||||
"5h"
|
||||
};
|
||||
draw_text_in_rect(hdc, &layout.head_label_rect, head_label_text, DT_CENTER);
|
||||
|
||||
// Head: big "X%" glyph centered.
|
||||
SelectObject(hdc, big_font);
|
||||
@@ -1449,16 +1643,31 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
};
|
||||
draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER);
|
||||
|
||||
// Tail: "7d" label (muted, left-aligned).
|
||||
// Tail: weekly percent (foreground color, aligned with its usage bar). Skipped
|
||||
// when the layout collapsed the rect at small widths. Foreground —
|
||||
// not the accent color the bar uses — because Codex teal #10A37F on
|
||||
// the light theme background only hits ~3.2:1 contrast, below WCAG
|
||||
// AA for small text. Adjacency to the bar carries the visual
|
||||
// grouping; we don't need hue to do it too.
|
||||
SelectObject(hdc, small_font);
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT);
|
||||
if let Some(pct) = inputs.weekly_pct {
|
||||
if layout.tail_usage_pct_rect.right > layout.tail_usage_pct_rect.left {
|
||||
let mut color = text_color;
|
||||
if pct >= 95.0 {
|
||||
let t = pulse_triangle(inputs.pulse_phase);
|
||||
color = brighten(color, t);
|
||||
}
|
||||
SetTextColor(hdc, COLORREF(color.into_colorref()));
|
||||
let weekly_pct_text = format!("{:.0}%", pct);
|
||||
draw_text_in_rect(hdc, &layout.tail_usage_pct_rect, &weekly_pct_text, DT_CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
// Tail: countdown (right-aligned).
|
||||
// Tail: weekly countdown aligned with its true remaining-time bar.
|
||||
SelectObject(hdc, main_font);
|
||||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||||
if !inputs.weekly_text.is_empty() {
|
||||
draw_text_in_rect(hdc, &layout.tail_countdown_rect, &inputs.weekly_text, DT_RIGHT);
|
||||
draw_text_in_rect(hdc, &layout.tail_time_text_rect, &inputs.weekly_text, DT_RIGHT);
|
||||
}
|
||||
|
||||
SelectObject(hdc, prev_font);
|
||||
|
||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
size_smaller = "Make smaller"
|
||||
size_larger = "Make larger"
|
||||
reset_size = "Reset size"
|
||||
controls = "Controls"
|
||||
control_left_click = "Left-click: details"
|
||||
control_right_click = "Right-click: menu"
|
||||
control_drag = "Drag: move/snap"
|
||||
control_ctrl_wheel = "Ctrl+Wheel: resize"
|
||||
control_tray_click = "Tray click: show/hide"
|
||||
tray_left_click = "Left-click: show/hide"
|
||||
language = "Language"
|
||||
system_default = "System default"
|
||||
check_for_updates = "Check for updates"
|
||||
|
||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "Windows起動時に開始"
|
||||
reset_position = "位置をリセット"
|
||||
size_smaller = "小さくする"
|
||||
size_larger = "大きくする"
|
||||
reset_size = "サイズをリセット"
|
||||
controls = "操作"
|
||||
control_left_click = "左クリック: 詳細"
|
||||
control_right_click = "右クリック: メニュー"
|
||||
control_drag = "ドラッグ: 移動/吸着"
|
||||
control_ctrl_wheel = "Ctrl+ホイール: サイズ変更"
|
||||
control_tray_click = "トレイ左クリック: 表示/非表示"
|
||||
tray_left_click = "左クリック: 表示/非表示"
|
||||
language = "言語"
|
||||
system_default = "システム既定"
|
||||
check_for_updates = "更新を確認"
|
||||
|
||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
||||
settings = "설정"
|
||||
start_with_windows = "Windows 시작 시 실행"
|
||||
reset_position = "위치 초기화"
|
||||
size_smaller = "작게"
|
||||
size_larger = "크게"
|
||||
reset_size = "크기 초기화"
|
||||
controls = "조작"
|
||||
control_left_click = "왼쪽 클릭: 상세"
|
||||
control_right_click = "오른쪽 클릭: 메뉴"
|
||||
control_drag = "드래그: 이동/스냅"
|
||||
control_ctrl_wheel = "Ctrl+휠: 크기 조절"
|
||||
control_tray_click = "트레이 클릭: 표시/숨김"
|
||||
tray_left_click = "왼쪽 클릭: 표시/숨김"
|
||||
language = "언어"
|
||||
system_default = "시스템 기본값"
|
||||
check_for_updates = "업데이트 확인"
|
||||
|
||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
||||
settings = "Cài đặt"
|
||||
start_with_windows = "Khởi động cùng Windows"
|
||||
reset_position = "Đặt lại vị trí"
|
||||
size_smaller = "Thu nhỏ"
|
||||
size_larger = "Phóng to"
|
||||
reset_size = "Đặt lại kích thước"
|
||||
controls = "Điều khiển"
|
||||
control_left_click = "Nhấp trái: chi tiết"
|
||||
control_right_click = "Nhấp phải: menu"
|
||||
control_drag = "Kéo: di chuyển/bám"
|
||||
control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ"
|
||||
control_tray_click = "Khay: hiện/ẩn"
|
||||
tray_left_click = "Nhấp trái: hiện/ẩn"
|
||||
language = "Ngôn ngữ"
|
||||
system_default = "Mặc định hệ thống"
|
||||
check_for_updates = "Kiểm tra cập nhật"
|
||||
|
||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
||||
settings = "設定"
|
||||
start_with_windows = "隨 Windows 啟動"
|
||||
reset_position = "重設位置"
|
||||
size_smaller = "縮小"
|
||||
size_larger = "放大"
|
||||
reset_size = "重設大小"
|
||||
controls = "操作"
|
||||
control_left_click = "左鍵: 詳細"
|
||||
control_right_click = "右鍵: 選單"
|
||||
control_drag = "拖曳: 移動/吸附"
|
||||
control_ctrl_wheel = "Ctrl+滾輪: 調整大小"
|
||||
control_tray_click = "系統匣: 顯示/隱藏"
|
||||
tray_left_click = "左鍵: 顯示/隱藏"
|
||||
language = "語言"
|
||||
system_default = "系統預設"
|
||||
check_for_updates = "檢查更新"
|
||||
|
||||
+81
-5
@@ -34,6 +34,16 @@ pub struct LocaleStrings {
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
pub size_smaller: String,
|
||||
pub size_larger: String,
|
||||
pub reset_size: String,
|
||||
pub controls: String,
|
||||
pub control_left_click: String,
|
||||
pub control_right_click: String,
|
||||
pub control_drag: String,
|
||||
pub control_ctrl_wheel: String,
|
||||
pub control_tray_click: String,
|
||||
pub tray_left_click: String,
|
||||
pub language: String,
|
||||
pub system_default: String,
|
||||
pub check_for_updates: String,
|
||||
@@ -158,9 +168,7 @@ impl I18n {
|
||||
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))
|
||||
})
|
||||
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)))
|
||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||
self.active = new_active;
|
||||
}
|
||||
@@ -183,7 +191,9 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
|
||||
}
|
||||
// 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 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());
|
||||
}
|
||||
@@ -192,7 +202,13 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
|
||||
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) {
|
||||
if key
|
||||
.split('-')
|
||||
.next()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
== Some(prefix)
|
||||
{
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
@@ -259,3 +275,63 @@ pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Durati
|
||||
};
|
||||
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_locales_parse_and_include_required_control_strings() {
|
||||
let mut has_fallback = false;
|
||||
for (expected_code, body) in RAW_LOCALES {
|
||||
let file = toml::from_str::<LocaleFile>(body)
|
||||
.unwrap_or_else(|e| panic!("locale {expected_code} failed to parse: {e}"));
|
||||
assert_eq!(file.code, *expected_code);
|
||||
has_fallback |= file.code == FALLBACK_CODE;
|
||||
|
||||
let strings = file.strings;
|
||||
for (name, value) in [
|
||||
("size_smaller", strings.size_smaller.as_str()),
|
||||
("size_larger", strings.size_larger.as_str()),
|
||||
("reset_size", strings.reset_size.as_str()),
|
||||
("controls", strings.controls.as_str()),
|
||||
("control_left_click", strings.control_left_click.as_str()),
|
||||
("control_right_click", strings.control_right_click.as_str()),
|
||||
("control_drag", strings.control_drag.as_str()),
|
||||
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
|
||||
("control_tray_click", strings.control_tray_click.as_str()),
|
||||
("tray_left_click", strings.tray_left_click.as_str()),
|
||||
] {
|
||||
assert!(
|
||||
!value.trim().is_empty(),
|
||||
"locale {expected_code} has empty {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(has_fallback, "fallback locale {FALLBACK_CODE} missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_schema_rejects_missing_or_malformed_control_strings() {
|
||||
let (_, fallback_body) = RAW_LOCALES
|
||||
.iter()
|
||||
.find(|(code, _)| *code == FALLBACK_CODE)
|
||||
.expect("fallback locale fixture must exist");
|
||||
|
||||
let missing_control =
|
||||
fallback_body.replace("tray_left_click = \"Left-click: show/hide\"\n", "");
|
||||
assert!(
|
||||
toml::from_str::<LocaleFile>(&missing_control).is_err(),
|
||||
"missing tray_left_click should fail locale deserialization"
|
||||
);
|
||||
|
||||
let malformed_control = fallback_body.replace(
|
||||
"control_tray_click = \"Tray click: show/hide\"",
|
||||
"control_tray_click = [\"Tray click: show/hide\"]",
|
||||
);
|
||||
assert!(
|
||||
toml::from_str::<LocaleFile>(&malformed_control).is_err(),
|
||||
"malformed control_tray_click should fail locale deserialization"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user