From 081a70a537092bb0381e19f58e366abaf7ac6f78 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 23 May 2026 18:17:25 +0700 Subject: [PATCH] feat(ui): improve bubble controls discoverability --- README.md | 11 +- plans/260523-ui-ux-improvement-plan/plan.md | 91 +++++++++++ src/app.rs | 166 ++++++++++++++++---- src/bubble.rs | 42 +++-- src/i18n/locales/en.toml | 10 ++ src/i18n/locales/ja.toml | 10 ++ src/i18n/locales/ko.toml | 10 ++ src/i18n/locales/vi.toml | 10 ++ src/i18n/locales/zh-TW.toml | 10 ++ src/i18n/mod.rs | 86 +++++++++- 10 files changed, 394 insertions(+), 52 deletions(-) create mode 100644 plans/260523-ui-ux-improvement-plan/plan.md diff --git a/README.md b/README.md index 65b01ce..82a31e4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ self-updater are all written from scratch against the same public APIs Codex usage as a percentage and a colored progress ring - Drag anywhere — the bubble snaps to monitor work-area edges when released -- Resize with `Ctrl + MouseWheel` on the bubble (32–128 pixels) +- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** → + **Make smaller / Make larger / Reset size** from the right-click menu + (140–360 logical pixels) - Left-click the bubble for an expanded panel with both **5h** and **7d** bars plus reset countdowns - Right-click for refresh, displayed models, update frequency, language, @@ -84,10 +86,11 @@ release to snap to the nearest edge if you let go close to one. - **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns) - **Right-click** for refresh, models, refresh frequency, language, "Start - with Windows", auto-update check (Disabled / Hourly / Daily / Weekly), - manual "Check for updates", exit + with Windows", controls, auto-update check (Disabled / Hourly / Daily / + Weekly), manual "Check for updates", exit - **Drag** anywhere — it floats on top of all other windows -- **Ctrl + MouseWheel** on the bubble to resize it +- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click + menu, to resize it - **Tray icon** (if enabled): left-click toggles the bubble visibility, right-click opens the same menu diff --git a/plans/260523-ui-ux-improvement-plan/plan.md b/plans/260523-ui-ux-improvement-plan/plan.md new file mode 100644 index 0000000..99c9f16 --- /dev/null +++ b/plans/260523-ui-ux-improvement-plan/plan.md @@ -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? diff --git a/src/app.rs b/src/app.rs index e190473..44dbc1d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -70,6 +70,9 @@ const IDM_START_WITH_WINDOWS: u16 = 30; const IDM_RESET_POSITION: u16 = 31; const IDM_VERSION_ACTION: u16 = 32; const IDM_RESTART: u16 = 33; +const IDM_SIZE_SMALLER: u16 = 34; +const IDM_SIZE_LARGER: u16 = 35; +const IDM_RESET_SIZE: u16 = 36; const IDM_LANG_SYSTEM: u16 = 40; // 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range // clear of it (and any future tray ids in the 5x band). @@ -401,7 +404,7 @@ fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) { } fn on_bubble_resized(_model: ProviderId, size_logical: i32) { - update_settings(|s| s.settings.bubble_size_logical = size_logical); + set_bubble_size(size_logical); } fn on_menu_command(id: u32, _owner_hwnd: HWND) { @@ -418,13 +421,14 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) { IDM_START_WITH_WINDOWS => toggle_startup(), IDM_RESET_POSITION => reset_positions(), IDM_VERSION_ACTION => version_action(), + IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL), + IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL), + IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE), IDM_UPDATE_AUTO_OFF => set_update_check_interval(None), IDM_UPDATE_AUTO_HOURLY => { set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS)) } - IDM_UPDATE_AUTO_DAILY => { - set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)) - } + IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)), IDM_UPDATE_AUTO_WEEKLY => { set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS)) } @@ -759,14 +763,7 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) { } else { None }, - tooltip: format!( - "{} {}: {} | {}: {}", - snap.i18n_strings.claude_label, - snap.i18n_strings.session_window, - entry.map(|e| e.primary_text.as_str()).unwrap_or(""), - snap.i18n_strings.weekly_window, - entry.map(|e| e.secondary_text.as_str()).unwrap_or(""), - ), + tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings), }); } if snap.settings.show_codex { @@ -778,19 +775,27 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) { } else { None }, - tooltip: format!( - "{} {}: {} | {}: {}", - snap.i18n_strings.chatgpt_label, - snap.i18n_strings.session_window, - entry.map(|e| e.primary_text.as_str()).unwrap_or(""), - snap.i18n_strings.weekly_window, - entry.map(|e| e.secondary_text.as_str()).unwrap_or(""), - ), + tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings), }); } tray::sync(snap.msg_hwnd.to_hwnd(), &icons); } +fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String { + let session = entry + .map(|e| e.primary_text.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("..."); + let weekly = entry + .map(|e| e.secondary_text.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("..."); + format!( + "{label}\n{}: {session}\n{}: {weekly}\n{}", + strings.session_window, strings.weekly_window, strings.tray_left_click + ) +} + fn handle_tray_action(action: TrayAction) { match action { TrayAction::None => {} @@ -926,6 +931,7 @@ struct ContextMenuSnapshot { widget_visible: bool, install_channel: InstallChannel, update_status: UpdateStatus, + bubble_size_logical: i32, } fn show_context_menu(owner_hwnd: HWND) { @@ -945,6 +951,7 @@ fn show_context_menu(owner_hwnd: HWND) { widget_visible: s.settings.widget_visible, install_channel: s.install_channel, update_status: s.update_status, + bubble_size_logical: s.settings.bubble_size_logical, }, None => return, }; @@ -986,13 +993,21 @@ fn show_context_menu(owner_hwnd: HWND) { models, IDM_MODEL_CLAUDE, &snap.strings.claude_label, - if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, + if snap.show_claude { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, ); append_item( models, IDM_MODEL_CHATGPT, &snap.strings.chatgpt_label, - if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, + if snap.show_chatgpt { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, ); append_submenu(menu, models, &snap.strings.models); @@ -1005,7 +1020,11 @@ fn show_context_menu(owner_hwnd: HWND) { settings_menu, IDM_START_WITH_WINDOWS, &snap.strings.start_with_windows, - if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, + if is_startup_enabled() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, ); append_item( settings_menu, @@ -1024,7 +1043,11 @@ fn show_context_menu(owner_hwnd: HWND) { lang, IDM_LANG_SYSTEM, &snap.strings.system_default, - if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, + if snap.language_override.is_none() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, ); for (i, (code, name)) in snap.available.iter().enumerate() { let id = IDM_LANG_BASE + i as u16; @@ -1052,7 +1075,12 @@ fn show_context_menu(owner_hwnd: HWND) { } else { MENU_ITEM_FLAGS(0) }; - append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags); + append_item( + settings_menu, + IDM_VERSION_ACTION, + &version_label, + version_flags, + ); let Ok(auto_update) = CreatePopupMenu() else { log::error!("CreatePopupMenu(auto_update) failed"); @@ -1088,11 +1116,58 @@ fn show_context_menu(owner_hwnd: HWND) { append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check); append_submenu(menu, settings_menu, &snap.strings.settings); + let Ok(controls) = CreatePopupMenu() else { + log::error!("CreatePopupMenu(controls) failed"); + let _ = DestroyMenu(menu); + return; + }; + append_item( + controls, + IDM_SIZE_SMALLER, + &snap.strings.size_smaller, + if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE { + MF_GRAYED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + append_item( + controls, + IDM_SIZE_LARGER, + &snap.strings.size_larger, + if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE { + MF_GRAYED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + append_item( + controls, + IDM_RESET_SIZE, + &snap.strings.reset_size, + if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE { + MF_GRAYED + } else { + MENU_ITEM_FLAGS(0) + }, + ); + let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null()); + append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED); + append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED); + append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED); + append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED); + append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED); + append_submenu(menu, controls, &snap.strings.controls); + append_item( menu, tray::IDM_TOGGLE_WIDGET, &snap.strings.show_widget, - if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }, + if snap.widget_visible { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }, ); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0)); @@ -1116,7 +1191,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) { fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) { let w = os::to_utf16_nul(label); unsafe { - let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr())); + let _ = AppendMenuW( + menu, + MF_POPUP, + submenu.0 as usize, + PCWSTR::from_raw(w.as_ptr()), + ); } } @@ -1181,7 +1261,9 @@ fn toggle_model(model: ProviderId) { ProviderId::Claude => settings.show_claude_code, ProviderId::ChatGpt => settings.show_codex, }; - let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied()); + let existing = lock_state() + .as_ref() + .and_then(|s| s.bubbles.get(&model).copied()); match (want, existing) { (true, None) => spawn_bubble(model, &settings, is_dark), (false, Some(h)) => { @@ -1244,6 +1326,34 @@ fn reset_positions() { spawn_poll_thread(); } +fn resize_bubbles(delta: i32) { + let current = lock_state() + .as_ref() + .map(|s| s.settings.bubble_size_logical) + .unwrap_or(bubble::DEFAULT_BUBBLE_SIZE); + set_bubble_size(current + delta); +} + +fn set_bubble_size(size_logical: i32) { + let (hwnds, snap) = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return; + }; + let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE); + if new_size == s.settings.bubble_size_logical { + return; + } + s.settings.bubble_size_logical = new_size; + let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::>(); + (hwnds, s.settings.clone()) + }; + settings::save(&snap); + for hwnd in hwnds { + bubble::set_size_logical(hwnd, snap.bubble_size_logical); + } +} + fn set_language(_dummy: Option<()>) { update_settings(|s| { s.i18n.set_active(None); diff --git a/src/bubble.rs b/src/bubble.rs index e0add90..a945aad 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -44,7 +44,7 @@ use crate::usage::ProviderId; pub const MIN_BUBBLE_SIZE: i32 = 140; pub const MAX_BUBBLE_SIZE: i32 = 360; pub const DEFAULT_BUBBLE_SIZE: i32 = 200; -const RESIZE_STEP: i32 = 20; +pub const RESIZE_STEP_LOGICAL: i32 = 20; const SNAP_ZONE_LOGICAL: i32 = 12; const CORNER_SNAP_ZONE_LOGICAL: i32 = 32; const CORNER_INSET_LOGICAL: i32 = 12; @@ -137,9 +137,7 @@ pub fn register_class() { /// message-loop dispatch. pub fn create(config: BubbleConfig) -> HWND { register_class(); - let initial_size_logical = config - .size_logical - .clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); + let initial_size_logical = config.size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); let dpi_for_create = crate::os::dpi::for_system(); let width_px = scale_to_dpi(initial_size_logical, dpi_for_create); let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create); @@ -366,9 +364,7 @@ pub fn position(hwnd: HWND) -> Option<(i32, i32)> { } pub fn model(hwnd: HWND) -> Option { - lock_bubbles() - .get(&(hwnd.0 as isize)) - .map(|b| b.model) + lock_bubbles().get(&(hwnd.0 as isize)).map(|b| b.model) } pub fn size_logical(hwnd: HWND) -> Option { @@ -468,7 +464,11 @@ unsafe extern "system" fn wnd_proc( const MK_CONTROL: u32 = 0x0008; if modifiers & MK_CONTROL != 0 { let delta = ((wparam.0 >> 16) & 0xFFFF) as i16; - let step = if delta > 0 { RESIZE_STEP } else { -RESIZE_STEP }; + let step = if delta > 0 { + RESIZE_STEP_LOGICAL + } else { + -RESIZE_STEP_LOGICAL + }; resize_step(hwnd, step); LRESULT(0) } else { @@ -583,12 +583,19 @@ fn lparam_to_point(lparam: LPARAM) -> POINT { // ---------- Resize / snap ---------- fn resize_step(hwnd: HWND, delta: i32) { + let Some(current) = size_logical(hwnd) else { + return; + }; + set_size_logical(hwnd, current + delta); +} + +pub fn set_size_logical(hwnd: HWND, size_logical: i32) { let (new_logical, dpi) = { let mut bubbles = lock_bubbles(); let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else { return; }; - let new_logical = (b.size_logical + delta).clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); + let new_logical = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); if new_logical == b.size_logical { return; } @@ -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_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_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; @@ -1119,7 +1125,8 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option 0.0 { - let mut color = crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct); + let mut color = + crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct); if pct >= 95.0 { let t = pulse_triangle(inputs.pulse_phase); color = brighten(color, t); @@ -1325,8 +1332,8 @@ fn render(hwnd: HWND) { ..Default::default() }; let mut bits: *mut c_void = std::ptr::null_mut(); - let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) - .unwrap_or_default(); + let dib = + CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default(); if dib.is_invalid() || bits.is_null() { let _ = DeleteDC(mem_dc); ReleaseDC(hwnd, screen_dc); @@ -1458,7 +1465,12 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) { SelectObject(hdc, main_font); SetTextColor(hdc, COLORREF(text_color.into_colorref())); if !inputs.weekly_text.is_empty() { - draw_text_in_rect(hdc, &layout.tail_countdown_rect, &inputs.weekly_text, DT_RIGHT); + draw_text_in_rect( + hdc, + &layout.tail_countdown_rect, + &inputs.weekly_text, + DT_RIGHT, + ); } SelectObject(hdc, prev_font); diff --git a/src/i18n/locales/en.toml b/src/i18n/locales/en.toml index 0b711dc..077a510 100644 --- a/src/i18n/locales/en.toml +++ b/src/i18n/locales/en.toml @@ -14,6 +14,16 @@ chatgpt_label = "Codex" settings = "Settings" start_with_windows = "Start with Windows" reset_position = "Reset position" +size_smaller = "Make smaller" +size_larger = "Make larger" +reset_size = "Reset size" +controls = "Controls" +control_left_click = "Left-click: details" +control_right_click = "Right-click: menu" +control_drag = "Drag: move/snap" +control_ctrl_wheel = "Ctrl+Wheel: resize" +control_tray_click = "Tray click: show/hide" +tray_left_click = "Left-click: show/hide" language = "Language" system_default = "System default" check_for_updates = "Check for updates" diff --git a/src/i18n/locales/ja.toml b/src/i18n/locales/ja.toml index bcc288a..2721252 100644 --- a/src/i18n/locales/ja.toml +++ b/src/i18n/locales/ja.toml @@ -14,6 +14,16 @@ chatgpt_label = "Codex" settings = "設定" start_with_windows = "Windows起動時に開始" reset_position = "位置をリセット" +size_smaller = "小さくする" +size_larger = "大きくする" +reset_size = "サイズをリセット" +controls = "操作" +control_left_click = "左クリック: 詳細" +control_right_click = "右クリック: メニュー" +control_drag = "ドラッグ: 移動/吸着" +control_ctrl_wheel = "Ctrl+ホイール: サイズ変更" +control_tray_click = "トレイ左クリック: 表示/非表示" +tray_left_click = "左クリック: 表示/非表示" language = "言語" system_default = "システム既定" check_for_updates = "更新を確認" diff --git a/src/i18n/locales/ko.toml b/src/i18n/locales/ko.toml index eadf73b..dade757 100644 --- a/src/i18n/locales/ko.toml +++ b/src/i18n/locales/ko.toml @@ -14,6 +14,16 @@ chatgpt_label = "Codex" settings = "설정" start_with_windows = "Windows 시작 시 실행" reset_position = "위치 초기화" +size_smaller = "작게" +size_larger = "크게" +reset_size = "크기 초기화" +controls = "조작" +control_left_click = "왼쪽 클릭: 상세" +control_right_click = "오른쪽 클릭: 메뉴" +control_drag = "드래그: 이동/스냅" +control_ctrl_wheel = "Ctrl+휠: 크기 조절" +control_tray_click = "트레이 클릭: 표시/숨김" +tray_left_click = "왼쪽 클릭: 표시/숨김" language = "언어" system_default = "시스템 기본값" check_for_updates = "업데이트 확인" diff --git a/src/i18n/locales/vi.toml b/src/i18n/locales/vi.toml index cf31803..a08db56 100644 --- a/src/i18n/locales/vi.toml +++ b/src/i18n/locales/vi.toml @@ -14,6 +14,16 @@ chatgpt_label = "Codex" settings = "Cài đặt" start_with_windows = "Khởi động cùng Windows" reset_position = "Đặt lại vị trí" +size_smaller = "Thu nhỏ" +size_larger = "Phóng to" +reset_size = "Đặt lại kích thước" +controls = "Điều khiển" +control_left_click = "Nhấp trái: chi tiết" +control_right_click = "Nhấp phải: menu" +control_drag = "Kéo: di chuyển/bám" +control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ" +control_tray_click = "Khay: hiện/ẩn" +tray_left_click = "Nhấp trái: hiện/ẩn" language = "Ngôn ngữ" system_default = "Mặc định hệ thống" check_for_updates = "Kiểm tra cập nhật" diff --git a/src/i18n/locales/zh-TW.toml b/src/i18n/locales/zh-TW.toml index b9900dd..7219fba 100644 --- a/src/i18n/locales/zh-TW.toml +++ b/src/i18n/locales/zh-TW.toml @@ -14,6 +14,16 @@ chatgpt_label = "Codex" settings = "設定" start_with_windows = "隨 Windows 啟動" reset_position = "重設位置" +size_smaller = "縮小" +size_larger = "放大" +reset_size = "重設大小" +controls = "操作" +control_left_click = "左鍵: 詳細" +control_right_click = "右鍵: 選單" +control_drag = "拖曳: 移動/吸附" +control_ctrl_wheel = "Ctrl+滾輪: 調整大小" +control_tray_click = "系統匣: 顯示/隱藏" +tray_left_click = "左鍵: 顯示/隱藏" language = "語言" system_default = "系統預設" check_for_updates = "檢查更新" diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 21e0525..14f9142 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -34,6 +34,16 @@ pub struct LocaleStrings { pub settings: String, pub start_with_windows: String, pub reset_position: String, + pub size_smaller: String, + pub size_larger: String, + pub reset_size: String, + pub controls: String, + pub control_left_click: String, + pub control_right_click: String, + pub control_drag: String, + pub control_ctrl_wheel: String, + pub control_tray_click: String, + pub tray_left_click: String, pub language: String, pub system_default: String, pub check_for_updates: String, @@ -158,9 +168,7 @@ impl I18n { pub fn set_active(&mut self, requested: Option<&str>) { let new_active = requested .and_then(|c| normalise(c, &self.available)) - .or_else(|| { - detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)) - }) + .or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))) .unwrap_or_else(|| FALLBACK_CODE.to_string()); self.active = new_active; } @@ -183,7 +191,9 @@ fn normalise(input: &str, available: &BTreeMap) } // Special-case: Traditional Chinese variants → zh-TW let lower = cleaned.to_ascii_lowercase(); - if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) { + if lower.starts_with("zh") + && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) + { if available.contains_key("zh-TW") { return Some("zh-TW".to_string()); } @@ -192,7 +202,13 @@ fn normalise(input: &str, available: &BTreeMap) let prefix = lower.split('-').next().unwrap_or(""); if !prefix.is_empty() { for key in available.keys() { - if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) { + if key + .split('-') + .next() + .map(str::to_ascii_lowercase) + .as_deref() + == Some(prefix) + { return Some(key.clone()); } } @@ -259,3 +275,63 @@ pub fn time_until_display_change(resets_at: Option) -> Option(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::(&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::(&malformed_control).is_err(), + "malformed control_tray_click should fail locale deserialization" + ); + } +}