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 | |
|---|---|---|---|
|
|
a3f1323154 | ||
|
|
081a70a537 | ||
|
|
391ad0cba2 | ||
|
|
77325b1e00 | ||
|
|
c3d01f36d2 | ||
|
|
7bbf80e5f7 | ||
|
|
8cbc3dda5b | ||
|
|
526786b902 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.12"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.12"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||||
|
|||||||
@@ -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
|
Codex usage as a percentage and a colored progress ring
|
||||||
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
||||||
released
|
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**
|
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
||||||
bars plus reset countdowns
|
bars plus reset countdowns
|
||||||
- Right-click for refresh, displayed models, update frequency, language,
|
- 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)
|
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
||||||
- **Right-click** for refresh, models, refresh frequency, language, "Start
|
- **Right-click** for refresh, models, refresh frequency, language, "Start
|
||||||
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
|
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
|
||||||
manual "Check for updates", exit
|
Weekly), manual "Check for updates", exit
|
||||||
- **Drag** anywhere — it floats on top of all other windows
|
- **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,
|
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||||
right-click opens the same menu
|
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?
|
||||||
+138
-28
@@ -70,6 +70,9 @@ const IDM_START_WITH_WINDOWS: u16 = 30;
|
|||||||
const IDM_RESET_POSITION: u16 = 31;
|
const IDM_RESET_POSITION: u16 = 31;
|
||||||
const IDM_VERSION_ACTION: u16 = 32;
|
const IDM_VERSION_ACTION: u16 = 32;
|
||||||
const IDM_RESTART: u16 = 33;
|
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;
|
const IDM_LANG_SYSTEM: u16 = 40;
|
||||||
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
|
// 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).
|
// clear of it (and any future tray ids in the 5x band).
|
||||||
@@ -401,7 +404,7 @@ fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_bubble_resized(_model: ProviderId, size_logical: 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) {
|
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||||
@@ -418,13 +421,14 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
|||||||
IDM_START_WITH_WINDOWS => toggle_startup(),
|
IDM_START_WITH_WINDOWS => toggle_startup(),
|
||||||
IDM_RESET_POSITION => reset_positions(),
|
IDM_RESET_POSITION => reset_positions(),
|
||||||
IDM_VERSION_ACTION => version_action(),
|
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_OFF => set_update_check_interval(None),
|
||||||
IDM_UPDATE_AUTO_HOURLY => {
|
IDM_UPDATE_AUTO_HOURLY => {
|
||||||
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
||||||
}
|
}
|
||||||
IDM_UPDATE_AUTO_DAILY => {
|
IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)),
|
||||||
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
|
|
||||||
}
|
|
||||||
IDM_UPDATE_AUTO_WEEKLY => {
|
IDM_UPDATE_AUTO_WEEKLY => {
|
||||||
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
||||||
}
|
}
|
||||||
@@ -759,14 +763,7 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
tooltip: format!(
|
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
|
||||||
"{} {}: {} | {}: {}",
|
|
||||||
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(""),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if snap.settings.show_codex {
|
if snap.settings.show_codex {
|
||||||
@@ -778,19 +775,27 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
tooltip: format!(
|
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
|
||||||
"{} {}: {} | {}: {}",
|
|
||||||
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(""),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
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) {
|
fn handle_tray_action(action: TrayAction) {
|
||||||
match action {
|
match action {
|
||||||
TrayAction::None => {}
|
TrayAction::None => {}
|
||||||
@@ -926,6 +931,7 @@ struct ContextMenuSnapshot {
|
|||||||
widget_visible: bool,
|
widget_visible: bool,
|
||||||
install_channel: InstallChannel,
|
install_channel: InstallChannel,
|
||||||
update_status: UpdateStatus,
|
update_status: UpdateStatus,
|
||||||
|
bubble_size_logical: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_context_menu(owner_hwnd: HWND) {
|
fn show_context_menu(owner_hwnd: HWND) {
|
||||||
@@ -945,6 +951,7 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
widget_visible: s.settings.widget_visible,
|
widget_visible: s.settings.widget_visible,
|
||||||
install_channel: s.install_channel,
|
install_channel: s.install_channel,
|
||||||
update_status: s.update_status,
|
update_status: s.update_status,
|
||||||
|
bubble_size_logical: s.settings.bubble_size_logical,
|
||||||
},
|
},
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
@@ -986,13 +993,21 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
models,
|
models,
|
||||||
IDM_MODEL_CLAUDE,
|
IDM_MODEL_CLAUDE,
|
||||||
&snap.strings.claude_label,
|
&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(
|
append_item(
|
||||||
models,
|
models,
|
||||||
IDM_MODEL_CHATGPT,
|
IDM_MODEL_CHATGPT,
|
||||||
&snap.strings.chatgpt_label,
|
&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);
|
append_submenu(menu, models, &snap.strings.models);
|
||||||
|
|
||||||
@@ -1005,7 +1020,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
settings_menu,
|
settings_menu,
|
||||||
IDM_START_WITH_WINDOWS,
|
IDM_START_WITH_WINDOWS,
|
||||||
&snap.strings.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(
|
append_item(
|
||||||
settings_menu,
|
settings_menu,
|
||||||
@@ -1024,7 +1043,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
lang,
|
lang,
|
||||||
IDM_LANG_SYSTEM,
|
IDM_LANG_SYSTEM,
|
||||||
&snap.strings.system_default,
|
&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() {
|
for (i, (code, name)) in snap.available.iter().enumerate() {
|
||||||
let id = IDM_LANG_BASE + i as u16;
|
let id = IDM_LANG_BASE + i as u16;
|
||||||
@@ -1052,7 +1075,12 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
} else {
|
} else {
|
||||||
MENU_ITEM_FLAGS(0)
|
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 {
|
let Ok(auto_update) = CreatePopupMenu() else {
|
||||||
log::error!("CreatePopupMenu(auto_update) failed");
|
log::error!("CreatePopupMenu(auto_update) failed");
|
||||||
@@ -1088,11 +1116,58 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
||||||
append_submenu(menu, settings_menu, &snap.strings.settings);
|
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(
|
append_item(
|
||||||
menu,
|
menu,
|
||||||
tray::IDM_TOGGLE_WIDGET,
|
tray::IDM_TOGGLE_WIDGET,
|
||||||
&snap.strings.show_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());
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
||||||
@@ -1116,7 +1191,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
|
|||||||
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
||||||
let w = os::to_utf16_nul(label);
|
let w = os::to_utf16_nul(label);
|
||||||
unsafe {
|
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 +1261,9 @@ fn toggle_model(model: ProviderId) {
|
|||||||
ProviderId::Claude => settings.show_claude_code,
|
ProviderId::Claude => settings.show_claude_code,
|
||||||
ProviderId::ChatGpt => settings.show_codex,
|
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) {
|
match (want, existing) {
|
||||||
(true, None) => spawn_bubble(model, &settings, is_dark),
|
(true, None) => spawn_bubble(model, &settings, is_dark),
|
||||||
(false, Some(h)) => {
|
(false, Some(h)) => {
|
||||||
@@ -1244,6 +1326,34 @@ fn reset_positions() {
|
|||||||
spawn_poll_thread();
|
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<()>) {
|
fn set_language(_dummy: Option<()>) {
|
||||||
update_settings(|s| {
|
update_settings(|s| {
|
||||||
s.i18n.set_active(None);
|
s.i18n.set_active(None);
|
||||||
|
|||||||
+373
-367
@@ -1,20 +1,21 @@
|
|||||||
// Floating rounded-rectangle bubble window.
|
// Floating stadium-shaped bubble window.
|
||||||
//
|
//
|
||||||
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
|
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
|
||||||
// Shape is achieved via per-pixel alpha (alpha=0 outside the rounded rect) and
|
// The shape is a stadium (rounded-rect with corner_radius = height/2). The left
|
||||||
// confirmed via WM_NCHITTEST returning HTCAPTION inside the rect, HTTRANSPARENT
|
// half is the "head" — a stroked progress ring around the 5h percentage glyph.
|
||||||
// outside. The OS handles drag automatically because HTCAPTION inside the
|
// The right half is the "tail" — small "7d" label, thin progress bar, countdown.
|
||||||
// rect puts the click into the system move loop.
|
|
||||||
//
|
//
|
||||||
// Layout: two horizontal bars stacked vertically — top = session (5h), bottom =
|
// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc)
|
||||||
// weekly (7d) — each followed by right-aligned "X% · Yh Zm" text. The aspect
|
// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB;
|
||||||
// ratio is fixed at BUBBLE_ASPECT (3:1) so `size_logical` is interpreted as
|
// GDI then overlays ClearType text on top; UpdateLayeredWindow blits the result
|
||||||
// width and the height is derived.
|
// to the screen with per-pixel alpha. WM_NCHITTEST returns HTCAPTION inside the
|
||||||
|
// stadium so the OS handles drag for free.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||||
|
|
||||||
|
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
use windows::Win32::Foundation::*;
|
use windows::Win32::Foundation::*;
|
||||||
use windows::Win32::Graphics::Gdi::*;
|
use windows::Win32::Graphics::Gdi::*;
|
||||||
@@ -43,7 +44,7 @@ use crate::usage::ProviderId;
|
|||||||
pub const MIN_BUBBLE_SIZE: i32 = 140;
|
pub const MIN_BUBBLE_SIZE: i32 = 140;
|
||||||
pub const MAX_BUBBLE_SIZE: i32 = 360;
|
pub const MAX_BUBBLE_SIZE: i32 = 360;
|
||||||
pub const DEFAULT_BUBBLE_SIZE: i32 = 200;
|
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 SNAP_ZONE_LOGICAL: i32 = 12;
|
||||||
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
|
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
|
||||||
const CORNER_INSET_LOGICAL: i32 = 12;
|
const CORNER_INSET_LOGICAL: i32 = 12;
|
||||||
@@ -52,28 +53,6 @@ const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
|
|||||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
||||||
const FULLSCREEN_POLL_MS: u32 = 1500;
|
const FULLSCREEN_POLL_MS: u32 = 1500;
|
||||||
|
|
||||||
/// Per-width breakpoint defining bar height, text font size, and row gap in
|
|
||||||
/// *logical* pixels. Picked so that the smallest bubble is still legible and
|
|
||||||
/// the largest doesn't waste vertical space.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct Breakpoint {
|
|
||||||
bar_h: i32,
|
|
||||||
font: i32,
|
|
||||||
row_gap: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn breakpoint_for_width_logical(w: i32) -> Breakpoint {
|
|
||||||
if w <= 140 {
|
|
||||||
Breakpoint { bar_h: 12, font: 11, row_gap: 4 }
|
|
||||||
} else if w <= 200 {
|
|
||||||
Breakpoint { bar_h: 16, font: 13, row_gap: 6 }
|
|
||||||
} else if w <= 280 {
|
|
||||||
Breakpoint { bar_h: 20, font: 15, row_gap: 8 }
|
|
||||||
} else {
|
|
||||||
Breakpoint { bar_h: 24, font: 17, row_gap: 10 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
|
/// (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
|
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
|
||||||
/// grows so the wider bars don't look anaemic.
|
/// grows so the wider bars don't look anaemic.
|
||||||
@@ -158,9 +137,7 @@ pub fn register_class() {
|
|||||||
/// message-loop dispatch.
|
/// message-loop dispatch.
|
||||||
pub fn create(config: BubbleConfig) -> HWND {
|
pub fn create(config: BubbleConfig) -> HWND {
|
||||||
register_class();
|
register_class();
|
||||||
let initial_size_logical = config
|
let initial_size_logical = config.size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||||
.size_logical
|
|
||||||
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
|
||||||
let dpi_for_create = crate::os::dpi::for_system();
|
let dpi_for_create = crate::os::dpi::for_system();
|
||||||
let width_px = scale_to_dpi(initial_size_logical, dpi_for_create);
|
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);
|
let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create);
|
||||||
@@ -387,9 +364,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn model(hwnd: HWND) -> Option<ProviderId> {
|
pub fn model(hwnd: HWND) -> Option<ProviderId> {
|
||||||
lock_bubbles()
|
lock_bubbles().get(&(hwnd.0 as isize)).map(|b| b.model)
|
||||||
.get(&(hwnd.0 as isize))
|
|
||||||
.map(|b| b.model)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size_logical(hwnd: HWND) -> Option<i32> {
|
pub fn size_logical(hwnd: HWND) -> Option<i32> {
|
||||||
@@ -489,7 +464,11 @@ unsafe extern "system" fn wnd_proc(
|
|||||||
const MK_CONTROL: u32 = 0x0008;
|
const MK_CONTROL: u32 = 0x0008;
|
||||||
if modifiers & MK_CONTROL != 0 {
|
if modifiers & MK_CONTROL != 0 {
|
||||||
let delta = ((wparam.0 >> 16) & 0xFFFF) as i16;
|
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);
|
resize_step(hwnd, step);
|
||||||
LRESULT(0)
|
LRESULT(0)
|
||||||
} else {
|
} else {
|
||||||
@@ -574,7 +553,7 @@ fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn corner_radius_px(height_px: i32) -> i32 {
|
fn corner_radius_px(height_px: i32) -> i32 {
|
||||||
(height_px / 3).max(4)
|
height_px / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
|
fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
|
||||||
@@ -604,12 +583,19 @@ fn lparam_to_point(lparam: LPARAM) -> POINT {
|
|||||||
// ---------- Resize / snap ----------
|
// ---------- Resize / snap ----------
|
||||||
|
|
||||||
fn resize_step(hwnd: HWND, delta: i32) {
|
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 (new_logical, dpi) = {
|
||||||
let mut bubbles = lock_bubbles();
|
let mut bubbles = lock_bubbles();
|
||||||
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else {
|
||||||
return;
|
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 {
|
if new_logical == b.size_logical {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -964,107 +950,299 @@ fn check_fullscreen(bubble_hwnd: HWND) {
|
|||||||
|
|
||||||
// ---------- Painting ----------
|
// ---------- Painting ----------
|
||||||
|
|
||||||
const ACCENT_STRIPE_W_LOGICAL: i32 = 4;
|
|
||||||
const LABEL_PAD_LOGICAL: i32 = 6;
|
|
||||||
// Sized for the widest countdown across all shipped locales. Korean
|
// Sized for the widest countdown across all shipped locales. Korean
|
||||||
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
|
// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current
|
||||||
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
|
// worst case; ASCII-only "999d" was too narrow and let CJK text spill
|
||||||
// out of the column. Update this when adding a locale with a longer
|
// out of the column. Update this when adding a locale with a longer
|
||||||
// suffix.
|
// suffix.
|
||||||
const COUNTDOWN_TEMPLATE: &str = "999시간";
|
const COUNTDOWN_TEMPLATE: &str = "999시간";
|
||||||
// Percent now lives in its own column between the bar and the countdown so
|
|
||||||
// the two numeric readouts ("44%" and "3h") sit next to each other for
|
|
||||||
// quick scanning, and the percent never has to fight the bar's fill colour
|
|
||||||
// for contrast.
|
|
||||||
const PERCENT_TEMPLATE: &str = "100%";
|
|
||||||
|
|
||||||
struct BarLayout {
|
/// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels.
|
||||||
/// Bubble width in pixels.
|
///
|
||||||
|
/// 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.
|
||||||
|
struct BubbleLayout {
|
||||||
canvas_w: i32,
|
canvas_w: i32,
|
||||||
/// Bubble height in pixels.
|
|
||||||
canvas_h: i32,
|
canvas_h: i32,
|
||||||
/// Corner radius of the rounded rectangle.
|
|
||||||
corner_radius: i32,
|
corner_radius: i32,
|
||||||
/// Accent stripe (left edge) in pixels — Claude orange or Codex neutral.
|
head_diameter: i32,
|
||||||
accent_right: i32,
|
ring_cx: f32,
|
||||||
/// Row label column ("5h" / "7d").
|
ring_cy: f32,
|
||||||
label_left: i32,
|
ring_radius: f32,
|
||||||
label_right: i32,
|
ring_stroke_w: f32,
|
||||||
/// Percent column ("44%"), rendered on the bubble background.
|
head_label_rect: RECT,
|
||||||
percent_left: i32,
|
head_pct_rect: RECT,
|
||||||
percent_right: i32,
|
tail_label_rect: RECT,
|
||||||
/// Bar geometry.
|
tail_bar_rect: RECT,
|
||||||
bar_left: i32,
|
tail_countdown_rect: RECT,
|
||||||
bar_right: i32,
|
big_font_px: i32,
|
||||||
bar_h: i32,
|
small_font_px: i32,
|
||||||
/// Right-side countdown text column.
|
main_font_px: i32,
|
||||||
right_text_left: i32,
|
|
||||||
right_text_right: i32,
|
|
||||||
/// Vertical positions (top edge of each row's bar).
|
|
||||||
row1_y: i32,
|
|
||||||
row2_y: i32,
|
|
||||||
/// Font size for the main text (percent + countdown).
|
|
||||||
font_px: i32,
|
|
||||||
/// Font size for the muted row labels — a notch smaller than `font_px`.
|
|
||||||
label_font_px: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BarLayout {
|
fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayout {
|
||||||
let bp = breakpoint_for_width_logical(size_logical);
|
|
||||||
let width_px = scale_to_dpi(size_logical, dpi);
|
let width_px = scale_to_dpi(size_logical, dpi);
|
||||||
let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi);
|
let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi);
|
||||||
let bar_h = scale_to_dpi(bp.bar_h, dpi);
|
let head_diameter = height_px;
|
||||||
let row_gap = scale_to_dpi(bp.row_gap, dpi);
|
|
||||||
let pad_x = scale_to_dpi(10, dpi);
|
|
||||||
let pad_y = ((height_px - bar_h * 2 - row_gap) / 2).max(scale_to_dpi(6, dpi));
|
|
||||||
let accent_w = scale_to_dpi(ACCENT_STRIPE_W_LOGICAL, dpi);
|
|
||||||
let label_pad = scale_to_dpi(LABEL_PAD_LOGICAL, dpi);
|
|
||||||
let corner_radius = scale_to_dpi((bp.bar_h + bp.row_gap).max(8), dpi).min(height_px / 2);
|
|
||||||
|
|
||||||
// Measure the worst-case strings against the real font so the columns are
|
let head_pad = scale_to_dpi(4, dpi);
|
||||||
// exactly wide enough — no more, no less.
|
let ring_stroke_w = scale_to_dpi(3, dpi).clamp(2, 4) as f32;
|
||||||
let font_px = scale_to_dpi(bp.font, dpi).max(scale_to_dpi(11, dpi));
|
let ring_cx = (head_diameter as f32) / 2.0;
|
||||||
let label_font_px = scale_to_dpi(bp.font - 2, dpi).max(scale_to_dpi(9, dpi));
|
let ring_cy = (height_px as f32) / 2.0;
|
||||||
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, font_px);
|
// Ring centerline: midway between outer and inner edge, then keep stroke
|
||||||
let percent_w = measure_text_w(mem_dc, PERCENT_TEMPLATE, font_px);
|
// inside the head padding. ring_radius is the centerline radius.
|
||||||
let label_w = measure_text_w(mem_dc, "5h", label_font_px)
|
let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32);
|
||||||
.max(measure_text_w(mem_dc, "7d", label_font_px));
|
let ring_radius = ring_outer - ring_stroke_w / 2.0;
|
||||||
|
|
||||||
// Layout (left → right):
|
let big_font_px = (head_diameter * 26 / 100).max(scale_to_dpi(11, dpi));
|
||||||
// [accent] [pad] [label] [pad] [bar] [pad] [percent] [pad] [countdown] [pad_x]
|
let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi));
|
||||||
let accent_left = 0;
|
let main_font_px = small_font_px;
|
||||||
let accent_right = accent_left + accent_w;
|
|
||||||
let label_left = accent_right + label_pad;
|
|
||||||
let label_right = label_left + label_w;
|
|
||||||
let bar_left = label_right + label_pad;
|
|
||||||
|
|
||||||
let right_text_right = width_px - pad_x;
|
let head_label_h = small_font_px + scale_to_dpi(2, dpi);
|
||||||
let right_text_left = (right_text_right - countdown_w).max(bar_left + scale_to_dpi(20, dpi));
|
let head_pct_h = big_font_px + scale_to_dpi(2, dpi);
|
||||||
let percent_right = (right_text_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
let label_pct_gap = (big_font_px * 15 / 100).max(scale_to_dpi(2, dpi));
|
||||||
let percent_left = (percent_right - percent_w).max(bar_left + scale_to_dpi(20, dpi));
|
let head_total_h = head_label_h + label_pct_gap + head_pct_h;
|
||||||
let bar_right = (percent_left - label_pad).max(bar_left + scale_to_dpi(20, dpi));
|
let head_text_top = (height_px - head_total_h) / 2;
|
||||||
|
let head_label_rect = RECT {
|
||||||
|
left: scale_to_dpi(4, dpi),
|
||||||
|
top: head_text_top,
|
||||||
|
right: head_diameter - scale_to_dpi(4, dpi),
|
||||||
|
bottom: head_text_top + head_label_h,
|
||||||
|
};
|
||||||
|
let head_pct_rect = RECT {
|
||||||
|
left: scale_to_dpi(4, dpi),
|
||||||
|
top: head_text_top + head_label_h + label_pct_gap,
|
||||||
|
right: head_diameter - scale_to_dpi(4, dpi),
|
||||||
|
bottom: head_text_top + head_total_h,
|
||||||
|
};
|
||||||
|
|
||||||
let row1_y = pad_y;
|
let tail_left = head_diameter;
|
||||||
let row2_y = pad_y + bar_h + row_gap;
|
let tail_right = width_px - scale_to_dpi(12, dpi);
|
||||||
|
let pad = scale_to_dpi(6, dpi);
|
||||||
|
|
||||||
BarLayout {
|
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 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;
|
||||||
|
|
||||||
|
BubbleLayout {
|
||||||
canvas_w: width_px,
|
canvas_w: width_px,
|
||||||
canvas_h: height_px,
|
canvas_h: height_px,
|
||||||
corner_radius,
|
corner_radius: height_px / 2,
|
||||||
accent_right,
|
head_diameter,
|
||||||
label_left,
|
ring_cx,
|
||||||
label_right,
|
ring_cy,
|
||||||
percent_left,
|
ring_radius,
|
||||||
percent_right,
|
ring_stroke_w,
|
||||||
bar_left,
|
head_label_rect,
|
||||||
bar_right,
|
head_pct_rect,
|
||||||
bar_h,
|
tail_label_rect: RECT {
|
||||||
right_text_left,
|
left: tail_label_left,
|
||||||
right_text_right,
|
top: 0,
|
||||||
row1_y,
|
right: tail_label_right,
|
||||||
row2_y,
|
bottom: height_px,
|
||||||
font_px,
|
},
|
||||||
label_font_px,
|
tail_bar_rect: RECT {
|
||||||
|
left: tail_bar_left,
|
||||||
|
top: tail_bar_top,
|
||||||
|
right: tail_bar_right,
|
||||||
|
bottom: tail_bar_top + tail_bar_h,
|
||||||
|
},
|
||||||
|
tail_countdown_rect: RECT {
|
||||||
|
left: tail_countdown_left,
|
||||||
|
top: 0,
|
||||||
|
right: tail_countdown_right,
|
||||||
|
bottom: height_px,
|
||||||
|
},
|
||||||
|
big_font_px,
|
||||||
|
small_font_px,
|
||||||
|
main_font_px,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the bubble's shape into a fresh tiny-skia `Pixmap`. The Pixmap is
|
||||||
|
/// premultiplied RGBA at one byte per channel — the caller copies it into the
|
||||||
|
/// GDI DIB section, then GDI text is overlaid on top.
|
||||||
|
fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pixmap> {
|
||||||
|
let mut pixmap = Pixmap::new(layout.canvas_w as u32, layout.canvas_h as u32)?;
|
||||||
|
pixmap.fill(tiny_skia::Color::TRANSPARENT);
|
||||||
|
|
||||||
|
let bg = if inputs.is_dark {
|
||||||
|
Color::from_hex("#1F1F1F")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#F3F3F3")
|
||||||
|
};
|
||||||
|
let track = if inputs.is_dark {
|
||||||
|
Color::from_hex("#3A3A3A")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#D6D6D6")
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Stadium background ----
|
||||||
|
{
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color(rgb_to_skia(bg));
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let r = (layout.canvas_h as f32) / 2.0;
|
||||||
|
let w = layout.canvas_w as f32;
|
||||||
|
let h = layout.canvas_h as f32;
|
||||||
|
|
||||||
|
// Two end-cap circles + middle rect. Overlap is fine — same color.
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.push_circle(r, r, r);
|
||||||
|
pb.push_circle(w - r, r, r);
|
||||||
|
if let Some(p) = pb.finish() {
|
||||||
|
pixmap.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
if let Some(rect) = Rect::from_xywh(r, 0.0, (w - 2.0 * r).max(0.0), h) {
|
||||||
|
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ring (5h) ----
|
||||||
|
{
|
||||||
|
// Track: full circle in muted color.
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color(rgb_to_skia(track));
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let mut stroke = Stroke::default();
|
||||||
|
stroke.width = layout.ring_stroke_w;
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.push_circle(layout.ring_cx, layout.ring_cy, layout.ring_radius);
|
||||||
|
if let Some(p) = pb.finish() {
|
||||||
|
pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active sweep arc.
|
||||||
|
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);
|
||||||
|
if pct >= 95.0 {
|
||||||
|
let t = pulse_triangle(inputs.pulse_phase);
|
||||||
|
color = brighten(color, t);
|
||||||
|
}
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color(rgb_to_skia(color));
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let mut stroke = Stroke::default();
|
||||||
|
stroke.width = layout.ring_stroke_w;
|
||||||
|
stroke.line_cap = LineCap::Round;
|
||||||
|
if let Some(path) =
|
||||||
|
build_arc(layout.ring_cx, layout.ring_cy, layout.ring_radius, sweep)
|
||||||
|
{
|
||||||
|
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tail bar (7d) ----
|
||||||
|
{
|
||||||
|
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 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);
|
||||||
|
if let Some(pct) = inputs.weekly_pct {
|
||||||
|
let frac = (pct.clamp(0.0, 100.0) / 100.0) as f32;
|
||||||
|
let fill_w = bar_w * frac;
|
||||||
|
if fill_w > 0.0 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
let clipped_w = fill_w.min(bar_w);
|
||||||
|
paint_pill(&mut pixmap, bar_x, bar_y, clipped_w, bar_h, cap, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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));
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.push_circle(x + cap, y + h * 0.5, cap);
|
||||||
|
pb.push_circle(x + w - cap, y + h * 0.5, cap);
|
||||||
|
if let Some(p) = pb.finish() {
|
||||||
|
pixmap.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
if let Some(rect) = Rect::from_xywh(x + cap, y, (w - 2.0 * cap).max(0.0), h) {
|
||||||
|
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgb_to_skia(c: Color) -> tiny_skia::Color {
|
||||||
|
tiny_skia::Color::from_rgba8(c.r, c.g, c.b, 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a clockwise arc path starting at 12 o'clock, sweeping `sweep_fraction`
|
||||||
|
/// of a full turn. Sampled — tiny-skia 0.11 lacks a direct arc primitive.
|
||||||
|
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_skia::Path> {
|
||||||
|
let segments = ((sweep_fraction * 64.0).ceil() as usize).max(1);
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
let start_angle: f32 = -std::f32::consts::FRAC_PI_2;
|
||||||
|
let total = sweep_fraction * std::f32::consts::TAU;
|
||||||
|
for i in 0..=segments {
|
||||||
|
let t = i as f32 / segments as f32;
|
||||||
|
let a = start_angle + t * total;
|
||||||
|
let x = cx + a.cos() * radius;
|
||||||
|
let y = cy + a.sin() * radius;
|
||||||
|
if i == 0 {
|
||||||
|
pb.move_to(x, y);
|
||||||
|
} else {
|
||||||
|
pb.line_to(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a premultiplied-RGBA `Pixmap` into the 32bpp BI_RGB DIB the bubble
|
||||||
|
/// uses for `UpdateLayeredWindow`. The DIB stores BGRA bytes (little-endian
|
||||||
|
/// `0xAARRGGBB` when read as u32); tiny-skia's premultiplied alpha is exactly
|
||||||
|
/// the format `AC_SRC_ALPHA` expects.
|
||||||
|
fn copy_pixmap_to_dib(pixmap: &Pixmap, dst: &mut [u32]) {
|
||||||
|
let src = pixmap.data();
|
||||||
|
let pixel_count = (pixmap.width() * pixmap.height()) as usize;
|
||||||
|
for i in 0..pixel_count {
|
||||||
|
let r = src[i * 4];
|
||||||
|
let g = src[i * 4 + 1];
|
||||||
|
let b = src[i * 4 + 2];
|
||||||
|
let a = src[i * 4 + 3];
|
||||||
|
dst[i] = ((a as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-stamp the alpha byte of every DIB pixel from the source `Pixmap`. Used
|
||||||
|
/// after GDI text rendering, which writes RGB but leaves the BI_RGB DIB's
|
||||||
|
/// "reserved" alpha byte at zero — making glyph pixels appear transparent
|
||||||
|
/// when `UpdateLayeredWindow` composites with `AC_SRC_ALPHA`.
|
||||||
|
fn restore_alpha_from_pixmap(pixmap: &Pixmap, dst: &mut [u32]) {
|
||||||
|
let src = pixmap.data();
|
||||||
|
let pixel_count = (pixmap.width() * pixmap.height()) as usize;
|
||||||
|
for i in 0..pixel_count {
|
||||||
|
let a = src[i * 4 + 3] as u32;
|
||||||
|
dst[i] = (dst[i] & 0x00FF_FFFF) | (a << 24);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,14 +1276,6 @@ fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pack an `Rgb` for direct write into a 32-bpp `BI_RGB` DIB. The DIB stores
|
|
||||||
/// bytes B,G,R,X in memory, so a little-endian u32 read is `(b) | (g<<8) | (r<<16)`.
|
|
||||||
/// Note this is the OPPOSITE byte order from a GDI `COLORREF` (which is
|
|
||||||
/// `(r) | (g<<8) | (b<<16)`) — don't confuse the two.
|
|
||||||
fn rgb_to_dib(c: Color) -> u32 {
|
|
||||||
(c.b as u32) | ((c.g as u32) << 8) | ((c.r as u32) << 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PaintInputs {
|
struct PaintInputs {
|
||||||
model: ProviderId,
|
model: ProviderId,
|
||||||
session_pct: Option<f64>,
|
session_pct: Option<f64>,
|
||||||
@@ -1147,7 +1317,7 @@ fn render(hwnd: HWND) {
|
|||||||
ReleaseDC(hwnd, screen_dc);
|
ReleaseDC(hwnd, screen_dc);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let layout = compute_layout(size_logical, dpi, mem_dc);
|
let layout = compute_bubble_layout(size_logical, dpi, mem_dc);
|
||||||
|
|
||||||
let bmi = BITMAPINFO {
|
let bmi = BITMAPINFO {
|
||||||
bmiHeader: BITMAPINFOHEADER {
|
bmiHeader: BITMAPINFOHEADER {
|
||||||
@@ -1162,8 +1332,8 @@ fn render(hwnd: HWND) {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||||
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
let dib =
|
||||||
.unwrap_or_default();
|
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
|
||||||
if dib.is_invalid() || bits.is_null() {
|
if dib.is_invalid() || bits.is_null() {
|
||||||
let _ = DeleteDC(mem_dc);
|
let _ = DeleteDC(mem_dc);
|
||||||
ReleaseDC(hwnd, screen_dc);
|
ReleaseDC(hwnd, screen_dc);
|
||||||
@@ -1174,17 +1344,27 @@ fn render(hwnd: HWND) {
|
|||||||
let pixel_count = (layout.canvas_w * layout.canvas_h) as usize;
|
let pixel_count = (layout.canvas_w * layout.canvas_h) as usize;
|
||||||
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
||||||
|
|
||||||
// Everything outside the rounded rect stays 0 (fully transparent).
|
// Paint shape via tiny-skia (AA), then copy into the DIB. GDI text
|
||||||
pixels.fill(0);
|
// overlays on top of the resulting bitmap.
|
||||||
|
let pixmap_opt = paint_bubble_pixmap(&layout, &inputs);
|
||||||
paint_background(pixels, &layout, &inputs);
|
if let Some(ref pixmap) = pixmap_opt {
|
||||||
paint_accent_stripe(pixels, &layout, inputs.model, inputs.is_dark);
|
copy_pixmap_to_dib(pixmap, pixels);
|
||||||
paint_bars(pixels, &layout, &inputs);
|
} else {
|
||||||
paint_text_layer(mem_dc, &layout, &inputs);
|
pixels.fill(0);
|
||||||
|
}
|
||||||
// Final alpha pass: alpha=255 inside the rounded rect, 0 outside. This
|
paint_bubble_text(mem_dc, &layout, &inputs);
|
||||||
// also lifts GDI-drawn text (which leaves alpha=0) into the visible plane.
|
// GDI text writes RGB into the 32bpp BI_RGB DIB but does not preserve
|
||||||
apply_alpha_mask(pixels, &layout);
|
// the alpha byte (per the BITMAPINFOHEADER contract: byte 3 is
|
||||||
|
// "reserved/0" for BI_RGB). UpdateLayeredWindow with AC_SRC_ALPHA then
|
||||||
|
// reads those zeroed alpha bytes and paints the glyph pixels as fully
|
||||||
|
// transparent — desktop bleeds through. Fix: re-stamp the alpha
|
||||||
|
// channel from the tiny-skia Pixmap we still have in scope. This
|
||||||
|
// preserves the AA alpha on the stadium's curved perimeter and forces
|
||||||
|
// glyph pixels back to the opacity tiny-skia computed for that
|
||||||
|
// location (255 in the interior, 0 outside).
|
||||||
|
if let Some(ref pixmap) = pixmap_opt {
|
||||||
|
restore_alpha_from_pixmap(pixmap, pixels);
|
||||||
|
}
|
||||||
|
|
||||||
let mut wr = RECT::default();
|
let mut wr = RECT::default();
|
||||||
let _ = GetWindowRect(hwnd, &mut wr);
|
let _ = GetWindowRect(hwnd, &mut wr);
|
||||||
@@ -1222,104 +1402,6 @@ fn render(hwnd: HWND) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint_background(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) {
|
|
||||||
let bg = if inputs.is_dark {
|
|
||||||
Color::from_hex("#1F1F1F")
|
|
||||||
} else {
|
|
||||||
Color::from_hex("#F3F3F3")
|
|
||||||
};
|
|
||||||
let bg_packed = rgb_to_dib(bg);
|
|
||||||
let blush_packed = rgb_to_dib(blend(bg, Color::from_hex("#C45020"), 0.15));
|
|
||||||
|
|
||||||
let row1_blush = inputs.session_pct.is_some_and(|p| p >= 95.0);
|
|
||||||
let row2_blush = inputs.weekly_pct.is_some_and(|p| p >= 95.0);
|
|
||||||
let row1_band = row_band(layout, layout.row1_y);
|
|
||||||
let row2_band = row_band(layout, layout.row2_y);
|
|
||||||
|
|
||||||
for y in 0..layout.canvas_h {
|
|
||||||
for x in 0..layout.canvas_w {
|
|
||||||
if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let in_row1 = row1_blush && y >= row1_band.0 && y < row1_band.1;
|
|
||||||
let in_row2 = row2_blush && y >= row2_band.0 && y < row2_band.1;
|
|
||||||
let pixel = if in_row1 || in_row2 { blush_packed } else { bg_packed };
|
|
||||||
pixels[(y * layout.canvas_w + x) as usize] = pixel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Top/bottom y-extent for a row's blush band — slightly taller than the bar
|
|
||||||
/// so the tint frames the row rather than just sitting under the fill.
|
|
||||||
fn row_band(layout: &BarLayout, row_top: i32) -> (i32, i32) {
|
|
||||||
let padding = (layout.bar_h / 4).max(2);
|
|
||||||
let top = (row_top - padding).max(0);
|
|
||||||
let bot = (row_top + layout.bar_h + padding).min(layout.canvas_h);
|
|
||||||
(top, bot)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_accent_stripe(pixels: &mut [u32], layout: &BarLayout, model: ProviderId, is_dark: bool) {
|
|
||||||
let stripe = rgb_to_dib(crate::usage_color::accent_color_for(model, is_dark));
|
|
||||||
for y in 0..layout.canvas_h {
|
|
||||||
for x in 0..layout.accent_right {
|
|
||||||
if !point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pixels[(y * layout.canvas_w + x) as usize] = stripe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_bars(pixels: &mut [u32], layout: &BarLayout, inputs: &PaintInputs) {
|
|
||||||
let track = if inputs.is_dark {
|
|
||||||
Color::from_hex("#3A3A3A")
|
|
||||||
} else {
|
|
||||||
Color::from_hex("#D6D6D6")
|
|
||||||
};
|
|
||||||
paint_one_bar(pixels, layout, layout.row1_y, inputs.session_pct, track, inputs);
|
|
||||||
paint_one_bar(pixels, layout, layout.row2_y, inputs.weekly_pct, track, inputs);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_one_bar(
|
|
||||||
pixels: &mut [u32],
|
|
||||||
layout: &BarLayout,
|
|
||||||
top: i32,
|
|
||||||
pct: Option<f64>,
|
|
||||||
track: Color,
|
|
||||||
inputs: &PaintInputs,
|
|
||||||
) {
|
|
||||||
let bar_w = layout.bar_right - layout.bar_left;
|
|
||||||
if bar_w <= 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let track_packed = rgb_to_dib(track);
|
|
||||||
for y in top..top + layout.bar_h {
|
|
||||||
for x in layout.bar_left..layout.bar_right {
|
|
||||||
pixels[(y * layout.canvas_w + x) as usize] = track_packed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(p) = pct else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let fill_w = ((p.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
|
|
||||||
if fill_w <= 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut accent_rgb = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, p);
|
|
||||||
if p >= 95.0 {
|
|
||||||
// Slow brightness triangle: 0.85 → 1.15 over 24 ticks (≈1.9s @ 80ms).
|
|
||||||
let t = pulse_triangle(inputs.pulse_phase);
|
|
||||||
accent_rgb = brighten(accent_rgb, t);
|
|
||||||
}
|
|
||||||
let accent_packed = rgb_to_dib(accent_rgb);
|
|
||||||
let end_x = (layout.bar_left + fill_w).min(layout.bar_right);
|
|
||||||
for y in top..top + layout.bar_h {
|
|
||||||
for x in layout.bar_left..end_x {
|
|
||||||
pixels[(y * layout.canvas_w + x) as usize] = accent_packed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triangle wave in [0, 1] with period 24 ticks. 0 at phase=0,12; 1 at phase=6,18.
|
/// Triangle wave in [0, 1] with period 24 ticks. 0 at phase=0,12; 1 at phase=6,18.
|
||||||
fn pulse_triangle(phase: u32) -> f64 {
|
fn pulse_triangle(phase: u32) -> f64 {
|
||||||
let p = (phase % 24) as i32;
|
let p = (phase % 24) as i32;
|
||||||
@@ -1338,20 +1420,9 @@ fn brighten(c: Color, t: f64) -> Color {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blend(a: Color, b: Color, t: f64) -> Color {
|
/// Paint the new bubble's text overlay via GDI: small "5h" label + big "%"
|
||||||
let t = t.clamp(0.0, 1.0);
|
/// glyph in the head, small "7d" label + countdown on the tail.
|
||||||
Color::new(
|
fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||||
((a.r as f64) * (1.0 - t) + (b.r as f64) * t).round() as u8,
|
|
||||||
((a.g as f64) * (1.0 - t) + (b.g as f64) * t).round() as u8,
|
|
||||||
((a.b as f64) * (1.0 - t) + (b.b as f64) * t).round() as u8,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One pass over the GDI text: row labels (muted) + percent + countdown.
|
|
||||||
/// The percent column lives between the bar and the countdown so the
|
|
||||||
/// numeric readouts cluster together for quick scanning, and the percent
|
|
||||||
/// text always sits on the bubble background — never on the bar fill.
|
|
||||||
fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
|
|
||||||
let text_color = if inputs.is_dark {
|
let text_color = if inputs.is_dark {
|
||||||
Color::from_hex("#EAEAEA")
|
Color::from_hex("#EAEAEA")
|
||||||
} else {
|
} else {
|
||||||
@@ -1365,36 +1436,64 @@ fn paint_text_layer(hdc: HDC, layout: &BarLayout, inputs: &PaintInputs) {
|
|||||||
|
|
||||||
let font_name = wide_str("Segoe UI");
|
let font_name = wide_str("Segoe UI");
|
||||||
unsafe {
|
unsafe {
|
||||||
let main_font = create_font(layout.font_px, &font_name, FW_NORMAL.0 as i32);
|
let big_font = create_font(layout.big_font_px, &font_name, FW_SEMIBOLD.0 as i32);
|
||||||
let bold_font = create_font(layout.font_px, &font_name, FW_SEMIBOLD.0 as i32);
|
let small_font = create_font(layout.small_font_px, &font_name, FW_NORMAL.0 as i32);
|
||||||
let label_font = create_font(layout.label_font_px, &font_name, FW_NORMAL.0 as i32);
|
let main_font = create_font(layout.main_font_px, &font_name, FW_NORMAL.0 as i32);
|
||||||
SetBkMode(hdc, TRANSPARENT);
|
SetBkMode(hdc, TRANSPARENT);
|
||||||
|
|
||||||
// Save the DC's original font so we can restore it before deleting
|
let prev_font = SelectObject(hdc, small_font);
|
||||||
// ours. DeleteObject silently fails on a still-selected HFONT,
|
|
||||||
// which would leak the handle on every paint frame.
|
// Head: "5h" label (muted, centered horizontally).
|
||||||
let prev_font = SelectObject(hdc, label_font);
|
|
||||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||||
draw_label(hdc, layout, layout.row1_y, "5h");
|
draw_text_in_rect(hdc, &layout.head_label_rect, "5h", DT_CENTER);
|
||||||
draw_label(hdc, layout, layout.row2_y, "7d");
|
|
||||||
|
|
||||||
// Percent in its own column (between bar and countdown).
|
// Head: big "X%" glyph centered.
|
||||||
SelectObject(hdc, bold_font);
|
SelectObject(hdc, big_font);
|
||||||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||||||
draw_percent(hdc, layout, layout.row1_y, inputs.session_pct);
|
let pct_text = match inputs.session_pct {
|
||||||
draw_percent(hdc, layout, layout.row2_y, inputs.weekly_pct);
|
Some(p) => format!("{:.0}%", p),
|
||||||
|
None => String::from("—"),
|
||||||
|
};
|
||||||
|
draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER);
|
||||||
|
|
||||||
// Countdown on the right.
|
// Tail: "7d" label (muted, left-aligned).
|
||||||
|
SelectObject(hdc, small_font);
|
||||||
|
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||||
|
draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT);
|
||||||
|
|
||||||
|
// Tail: countdown (right-aligned).
|
||||||
SelectObject(hdc, main_font);
|
SelectObject(hdc, main_font);
|
||||||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||||||
draw_countdown(hdc, layout, layout.row1_y, &inputs.session_text);
|
if !inputs.weekly_text.is_empty() {
|
||||||
draw_countdown(hdc, layout, layout.row2_y, &inputs.weekly_text);
|
draw_text_in_rect(
|
||||||
|
hdc,
|
||||||
|
&layout.tail_countdown_rect,
|
||||||
|
&inputs.weekly_text,
|
||||||
|
DT_RIGHT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Restore the original font, then it is safe to delete ours.
|
|
||||||
SelectObject(hdc, prev_font);
|
SelectObject(hdc, prev_font);
|
||||||
|
let _ = DeleteObject(big_font);
|
||||||
|
let _ = DeleteObject(small_font);
|
||||||
let _ = DeleteObject(main_font);
|
let _ = DeleteObject(main_font);
|
||||||
let _ = DeleteObject(bold_font);
|
}
|
||||||
let _ = DeleteObject(label_font);
|
}
|
||||||
|
|
||||||
|
/// Draw `text` into `rect` with the given horizontal alignment flag, vertically
|
||||||
|
/// centered. The DT_NOCLIP flag preserves ascenders/descenders that would
|
||||||
|
/// otherwise be clipped by tight rects.
|
||||||
|
fn draw_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT) {
|
||||||
|
let mut buf = wide_str(text);
|
||||||
|
let len_no_nul = buf.len().saturating_sub(1);
|
||||||
|
let mut r = *rect;
|
||||||
|
unsafe {
|
||||||
|
let _ = DrawTextW(
|
||||||
|
hdc,
|
||||||
|
&mut buf[..len_no_nul],
|
||||||
|
&mut r,
|
||||||
|
halign | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1419,99 +1518,6 @@ fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_label(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) {
|
|
||||||
let mut text_w = wide_str(text);
|
|
||||||
let len_no_nul = text_w.len().saturating_sub(1);
|
|
||||||
let mut rect = RECT {
|
|
||||||
left: layout.label_left,
|
|
||||||
top: row_top - 2,
|
|
||||||
right: layout.label_right,
|
|
||||||
bottom: row_top + layout.bar_h + 2,
|
|
||||||
};
|
|
||||||
unsafe {
|
|
||||||
let _ = DrawTextW(
|
|
||||||
hdc,
|
|
||||||
&mut text_w[..len_no_nul],
|
|
||||||
&mut rect,
|
|
||||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_percent(hdc: HDC, layout: &BarLayout, row_top: i32, pct: Option<f64>) {
|
|
||||||
let Some(p) = pct else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let text = format!("{:.0}%", p);
|
|
||||||
let mut text_buf = wide_str(&text);
|
|
||||||
let len_no_nul = text_buf.len().saturating_sub(1);
|
|
||||||
let mut rect = RECT {
|
|
||||||
left: layout.percent_left,
|
|
||||||
top: row_top,
|
|
||||||
right: layout.percent_right,
|
|
||||||
bottom: row_top + layout.bar_h,
|
|
||||||
};
|
|
||||||
unsafe {
|
|
||||||
let _ = DrawTextW(
|
|
||||||
hdc,
|
|
||||||
&mut text_buf[..len_no_nul],
|
|
||||||
&mut rect,
|
|
||||||
DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_countdown(hdc: HDC, layout: &BarLayout, row_top: i32, text: &str) {
|
|
||||||
if text.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Left-align so the countdown sits right next to the bar with only the
|
|
||||||
// `label_pad` gap. Right-aligning to the bubble's far edge left a visible
|
|
||||||
// float between bar end and number.
|
|
||||||
let mut text_w = wide_str(text);
|
|
||||||
let len_no_nul = text_w.len().saturating_sub(1);
|
|
||||||
let mut rect = RECT {
|
|
||||||
left: layout.right_text_left,
|
|
||||||
top: row_top - 2,
|
|
||||||
right: layout.right_text_right,
|
|
||||||
bottom: row_top + layout.bar_h + 2,
|
|
||||||
};
|
|
||||||
unsafe {
|
|
||||||
let _ = DrawTextW(
|
|
||||||
hdc,
|
|
||||||
&mut text_w[..len_no_nul],
|
|
||||||
&mut rect,
|
|
||||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP | DT_END_ELLIPSIS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_alpha_mask(pixels: &mut [u32], layout: &BarLayout) {
|
|
||||||
for y in 0..layout.canvas_h {
|
|
||||||
for x in 0..layout.canvas_w {
|
|
||||||
let idx = (y * layout.canvas_w + x) as usize;
|
|
||||||
if point_in_rounded_rect(x, y, layout.canvas_w, layout.canvas_h, layout.corner_radius) {
|
|
||||||
pixels[idx] |= 0xFF00_0000;
|
|
||||||
} else {
|
|
||||||
pixels[idx] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relative luminance of an sRGB color in 0..255 space. Cheap approximation
|
|
||||||
/// of the Rec. 709 coefficients — good enough for "should the text on this
|
|
||||||
/// pixel be white or black?" decisions.
|
|
||||||
fn luminance(c: Color) -> u32 {
|
|
||||||
(c.r as u32 * 299 + c.g as u32 * 587 + c.b as u32 * 114) / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true when `c` is light enough that black text is more readable
|
|
||||||
/// than white text on top of it.
|
|
||||||
fn use_dark_text_over(c: Color) -> bool {
|
|
||||||
luminance(c) >= 150
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i32) {
|
fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i32) {
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
start_with_windows = "Start with Windows"
|
start_with_windows = "Start with Windows"
|
||||||
reset_position = "Reset position"
|
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"
|
language = "Language"
|
||||||
system_default = "System default"
|
system_default = "System default"
|
||||||
check_for_updates = "Check for updates"
|
check_for_updates = "Check for updates"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "設定"
|
settings = "設定"
|
||||||
start_with_windows = "Windows起動時に開始"
|
start_with_windows = "Windows起動時に開始"
|
||||||
reset_position = "位置をリセット"
|
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 = "言語"
|
language = "言語"
|
||||||
system_default = "システム既定"
|
system_default = "システム既定"
|
||||||
check_for_updates = "更新を確認"
|
check_for_updates = "更新を確認"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "설정"
|
settings = "설정"
|
||||||
start_with_windows = "Windows 시작 시 실행"
|
start_with_windows = "Windows 시작 시 실행"
|
||||||
reset_position = "위치 초기화"
|
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 = "언어"
|
language = "언어"
|
||||||
system_default = "시스템 기본값"
|
system_default = "시스템 기본값"
|
||||||
check_for_updates = "업데이트 확인"
|
check_for_updates = "업데이트 확인"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "Cài đặt"
|
settings = "Cài đặt"
|
||||||
start_with_windows = "Khởi động cùng Windows"
|
start_with_windows = "Khởi động cùng Windows"
|
||||||
reset_position = "Đặt lại vị trí"
|
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ữ"
|
language = "Ngôn ngữ"
|
||||||
system_default = "Mặc định hệ thống"
|
system_default = "Mặc định hệ thống"
|
||||||
check_for_updates = "Kiểm tra cập nhật"
|
check_for_updates = "Kiểm tra cập nhật"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "設定"
|
settings = "設定"
|
||||||
start_with_windows = "隨 Windows 啟動"
|
start_with_windows = "隨 Windows 啟動"
|
||||||
reset_position = "重設位置"
|
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 = "語言"
|
language = "語言"
|
||||||
system_default = "系統預設"
|
system_default = "系統預設"
|
||||||
check_for_updates = "檢查更新"
|
check_for_updates = "檢查更新"
|
||||||
|
|||||||
+81
-5
@@ -34,6 +34,16 @@ pub struct LocaleStrings {
|
|||||||
pub settings: String,
|
pub settings: String,
|
||||||
pub start_with_windows: String,
|
pub start_with_windows: String,
|
||||||
pub reset_position: 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 language: String,
|
||||||
pub system_default: String,
|
pub system_default: String,
|
||||||
pub check_for_updates: String,
|
pub check_for_updates: String,
|
||||||
@@ -158,9 +168,7 @@ impl I18n {
|
|||||||
pub fn set_active(&mut self, requested: Option<&str>) {
|
pub fn set_active(&mut self, requested: Option<&str>) {
|
||||||
let new_active = requested
|
let new_active = requested
|
||||||
.and_then(|c| normalise(c, &self.available))
|
.and_then(|c| normalise(c, &self.available))
|
||||||
.or_else(|| {
|
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)))
|
||||||
detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||||
self.active = new_active;
|
self.active = new_active;
|
||||||
}
|
}
|
||||||
@@ -183,7 +191,9 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
|
|||||||
}
|
}
|
||||||
// Special-case: Traditional Chinese variants → zh-TW
|
// Special-case: Traditional Chinese variants → zh-TW
|
||||||
let lower = cleaned.to_ascii_lowercase();
|
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") {
|
if available.contains_key("zh-TW") {
|
||||||
return Some("zh-TW".to_string());
|
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("");
|
let prefix = lower.split('-').next().unwrap_or("");
|
||||||
if !prefix.is_empty() {
|
if !prefix.is_empty() {
|
||||||
for key in available.keys() {
|
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());
|
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))
|
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