Compare commits

..
4 Commits
Author SHA1 Message Date
tiennm99 a3f1323154 chore: bump version to 0.2.0 2026-05-23 18:18:34 +07:00
tiennm99 081a70a537 feat(ui): improve bubble controls discoverability 2026-05-23 18:17:25 +07:00
tiennm99 391ad0cba2 chore: bump version to 0.1.15 2026-05-23 12:54:46 +07:00
tiennm99 77325b1e00 fix(bubble): restore alpha after GDI text so glyphs aren't transparent
User report on v0.1.14: text appears semi-transparent, desktop wallpaper
bleeds through glyph pixels.

Root cause: GDI's DrawTextW writes only RGB into 32bpp BI_RGB DIBs — the
"reserved" alpha byte (byte 3) is not preserved per the BITMAPINFOHEADER
contract. When UpdateLayeredWindow later composites with AC_SRC_ALPHA, it
reads alpha=0 at every glyph pixel and shows them as fully transparent.

The pre-v0.1.13 pipeline worked around this with an apply_alpha_mask
post-pass that OR'd 0xFF000000 into every pixel inside the rounded rect.
The stadium-shape rewrite (526786b) removed it on the false assumption
that tiny-skia's per-pixel alpha would "stick" through subsequent GDI
writes — but GDI runs *after* tiny-skia in the pipeline, so any pixel
GDI text writes to loses the alpha that tiny-skia set.

Fix: re-stamp the alpha channel from the original Pixmap after the GDI
text overlay. This restores tiny-skia's exact alpha values (255 in the
stadium interior, partial on the AA curved perimeter, 0 outside),
including the AA fade at the stadium's rounded ends.

Implementation:
- new helper `restore_alpha_from_pixmap(pixmap, dst)` next to the
  existing `copy_pixmap_to_dib`
- hoist `pixmap` out of the if-let arm in render() so it survives until
  after `paint_bubble_text`
- call `restore_alpha_from_pixmap` post-text

Two parallel reviewers (debugger + code-reviewer) converged on the same
diagnosis; the debugger preferred this approach for its simplicity and
because it's robust to any GDI behavior (whether alpha is zeroed,
untouched, or scribbled on, we overwrite with the known-good value).

Build clean.
2026-05-23 12:54:13 +07:00
12 changed files with 424 additions and 56 deletions
Generated
+1 -1
View File
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.1.14" version = "0.2.0"
dependencies = [ dependencies = [
"dirs", "dirs",
"embed-resource", "embed-resource",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "claude-code-usage-bubble" name = "claude-code-usage-bubble"
version = "0.1.14" 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"
+7 -4
View File
@@ -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 (32128 pixels) - Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls**
**Make smaller / Make larger / Reset size** from the right-click menu
(140360 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
View File
@@ -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);
+55 -17
View File
@@ -44,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;
@@ -137,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);
@@ -366,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> {
@@ -468,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 {
@@ -583,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;
} }
@@ -1022,8 +1029,7 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
let tail_countdown_right = tail_right; let tail_countdown_right = tail_right;
let tail_countdown_left = tail_countdown_right - countdown_w; let tail_countdown_left = tail_countdown_right - countdown_w;
let tail_bar_left = tail_label_right + pad; let tail_bar_left = tail_label_right + pad;
let tail_bar_right = let tail_bar_right = (tail_countdown_left - pad).max(tail_bar_left + scale_to_dpi(20, dpi));
(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_h = scale_to_dpi(5, dpi);
let tail_bar_top = (height_px - tail_bar_h) / 2; let tail_bar_top = (height_px - tail_bar_h) / 2;
@@ -1119,7 +1125,8 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
if let Some(pct) = inputs.session_pct { if let Some(pct) = inputs.session_pct {
let sweep = (pct.clamp(0.0, 100.0) / 100.0) as f32; let sweep = (pct.clamp(0.0, 100.0) / 100.0) as f32;
if sweep > 0.0 { 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 { if pct >= 95.0 {
let t = pulse_triangle(inputs.pulse_phase); let t = pulse_triangle(inputs.pulse_phase);
color = brighten(color, t); color = brighten(color, t);
@@ -1226,6 +1233,19 @@ fn copy_pixmap_to_dib(pixmap: &Pixmap, dst: &mut [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);
}
}
fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 { fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 {
use windows::Win32::Foundation::SIZE; use windows::Win32::Foundation::SIZE;
let font_name = wide_str("Segoe UI"); let font_name = wide_str("Segoe UI");
@@ -1312,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);
@@ -1326,12 +1346,25 @@ fn render(hwnd: HWND) {
// Paint shape via tiny-skia (AA), then copy into the DIB. GDI text // Paint shape via tiny-skia (AA), then copy into the DIB. GDI text
// overlays on top of the resulting bitmap. // overlays on top of the resulting bitmap.
if let Some(pixmap) = paint_bubble_pixmap(&layout, &inputs) { let pixmap_opt = paint_bubble_pixmap(&layout, &inputs);
copy_pixmap_to_dib(&pixmap, pixels); if let Some(ref pixmap) = pixmap_opt {
copy_pixmap_to_dib(pixmap, pixels);
} else { } else {
pixels.fill(0); pixels.fill(0);
} }
paint_bubble_text(mem_dc, &layout, &inputs); paint_bubble_text(mem_dc, &layout, &inputs);
// GDI text writes RGB into the 32bpp BI_RGB DIB but does not preserve
// 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);
@@ -1432,7 +1465,12 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
SelectObject(hdc, main_font); SelectObject(hdc, main_font);
SetTextColor(hdc, COLORREF(text_color.into_colorref())); SetTextColor(hdc, COLORREF(text_color.into_colorref()));
if !inputs.weekly_text.is_empty() { 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_countdown_rect,
&inputs.weekly_text,
DT_RIGHT,
);
} }
SelectObject(hdc, prev_font); SelectObject(hdc, prev_font);
+10
View File
@@ -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"
+10
View File
@@ -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 = "更新を確認"
+10
View File
@@ -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 = "업데이트 확인"
+10
View File
@@ -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"
+10
View File
@@ -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
View File
@@ -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"
);
}
}