diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index 00b268abdf..65c5b8ff1b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -28,9 +28,6 @@ pub fn dismiss_tray_panel(app: tauri::AppHandle) -> Result<(), String> { crate::shell::flyout_window::hide(&app) } -/// Arm the gesture blur guard before a resize-grip drag or drag-reorder -/// gesture starts its Win32/OLE modal loop, so the transient -/// `Focused(false)` that loop produces doesn't auto-hide the flyout. #[tauri::command] pub fn begin_flyout_gesture(app: tauri::AppHandle) -> Result<(), String> { let state = app @@ -78,9 +75,8 @@ pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { } /// Reveal the flyout window after the frontend's first layout pass. Called by -/// `useTrayPanelLayout` once content has been measured/auto-fit (or the -/// remembered fixed size re-applied), so Windows never shows a pre-measure -/// blank/backing frame. +/// `useTrayPanelLayout` once content has been measured and auto-fit, so +/// Windows never shows a pre-measure blank/backing frame. /// /// No-ops when the flyout window doesn't exist or no one-shot reveal is pending. /// The window takes focus only as far as the pending reveal's activation @@ -116,37 +112,6 @@ pub fn close_settings_window( crate::shell::settings_window::dismiss(&app, &window) } -/// Persist a user-chosen size (logical px) for the "Pop Out Dashboard" flyout -/// window. Only the size is stored (via a size-only `StoredSize` entry — no -/// fabricated `x`/`y`); the position is tracked separately, only once the -/// user drags the flyout. The frontend calls this on genuine user drag-resizes, not on its own -/// auto-fit resizes, so auto-fit sizes never freeze the panel. -#[tauri::command] -pub fn set_flyout_size(width: f64, height: f64) -> Result<(), String> { - let width = (width.round() as i64).clamp(1, i64::from(u32::MAX)) as u32; - let height = (height.round() as i64).clamp(1, i64::from(u32::MAX)) as u32; - crate::shell::flyout_window::save_stored_size(width, height); - Ok(()) -} - -/// Forget where the user dragged the flyout and anchor it to the tray again -/// (double-click on the panel's move handle). -#[tauri::command] -pub fn reset_flyout_position(app: tauri::AppHandle) -> Result<(), String> { - crate::shell::flyout_window::reset_position(&app) -} - -/// Return the remembered flyout size, if the user has manually resized it. -/// The frontend uses this to decide whether to auto-fit (no stored size) or -/// honor the user's size (stored) on open. Transparently migrates a -/// pre-existing size stored under the legacy `SurfaceMode::TrayPanel` -/// shared-window geometry key (from before the flyout became its own -/// window), so upgrading users don't lose their remembered size. -#[tauri::command] -pub fn flyout_stored_size() -> Result, String> { - Ok(crate::shell::flyout_window::stored_size()) -} - #[tauri::command] pub fn get_current_surface_state( state: tauri::State<'_, Mutex>, diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 4355089961..26d6a20f9d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -170,6 +170,39 @@ pub fn get_work_area_rect(app: tauri::AppHandle) -> Result // ── Misc UX ──────────────────────────────────────────────────────────── +#[tauri::command] +pub fn get_system_accent_color() -> Option { + system_accent_abgr().map(abgr_to_hex) +} + +#[cfg(windows)] +fn system_accent_abgr() -> Option { + use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + [ + ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Accent", + "AccentColorMenu", + ), + (r"Software\Microsoft\Windows\DWM", "AccentColor"), + ] + .into_iter() + .find_map(|(path, name)| hkcu.open_subkey(path).ok()?.get_value::(name).ok()) +} + +#[cfg(not(windows))] +fn system_accent_abgr() -> Option { + None +} + +/// Windows stores accent colors as 0xAABBGGRR. +fn abgr_to_hex(value: u32) -> String { + let [r, g, b, _] = value.to_le_bytes(); + format!("#{r:02x}{g:02x}{b:02x}") +} + #[tauri::command] pub fn play_notification_sound( event: codexbar::sound::NotificationSoundEvent, @@ -425,6 +458,12 @@ fn copilot_label_matches_login(account: &TokenAccount, login: Option<&str>) -> b mod tests { use super::*; + #[test] + fn accent_registry_dword_reads_as_abgr() { + assert_eq!(abgr_to_hex(0xffd4_7800), "#0078d4"); + assert_eq!(abgr_to_hex(0xff00_b9ff), "#ffb900"); + } + #[test] fn dashboard_url_resolves_from_codex_provider_metadata() { assert_eq!( diff --git a/apps/desktop-tauri/src-tauri/src/geometry_store.rs b/apps/desktop-tauri/src-tauri/src/geometry_store.rs index c5945c2801..928970bfe7 100644 --- a/apps/desktop-tauri/src-tauri/src/geometry_store.rs +++ b/apps/desktop-tauri/src-tauri/src/geometry_store.rs @@ -1,13 +1,6 @@ //! Persistent window-geometry store for the Tauri desktop shell. //! -//! Remembers position (and size where applicable) for detached user surfaces: -//! PopOut and Settings. -//! -//! The flyout window is anchored to the tray until the user drags it; only -//! then is its position stored (`shell::flyout_placement`). Its SIZE is -//! remembered via the size-only [`StoredSize`] entries below, kept separate -//! from [`StoredGeometry`] so a resize of a tray-anchored flyout never carries -//! fabricated `x`/`y` coordinates. +//! Remembers position (and size where applicable) for detached user surfaces. use std::fs; use std::path::PathBuf; @@ -35,16 +28,6 @@ pub struct StoredGeometry { pub height: Option, } -/// A size-only persisted entry, for windows that are always re-anchored (never -/// remember position) so storing `x`/`y` would be fabricated data. Used by the -/// detached flyout window, which is anchored above the tray on every open — -/// only its user-chosen width/height is meaningful to remember. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct StoredSize { - pub width: u32, - pub height: u32, -} - /// All persisted geometries keyed by surface mode string (`settings`, ...). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GeometryFile { @@ -52,12 +35,6 @@ pub struct GeometryFile { pub version: u32, #[serde(default)] pub entries: std::collections::BTreeMap, - /// Size-only entries (no position), keyed by an arbitrary label (e.g. the - /// `"flyout"` window). Kept in a separate map — rather than reusing - /// `entries` with a fabricated `x: 0, y: 0` — so the on-disk shape can't - /// be misread as a remembered position. - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub size_entries: std::collections::BTreeMap, } fn geometry_path() -> Option { @@ -70,16 +47,9 @@ fn geometry_path() -> Option { /// Surface modes eligible for geometry persistence. /// /// - `Hidden`: never remembered. -/// - `TrayPanel`: remembered for SIZE — the "Pop Out Dashboard" flyout is always -/// re-anchored above the tray icon (its position comes from -/// `default_surface_position`, which ignores the stored x/y), but its -/// width/height persist so a user resize sticks across opens. /// - `PopOut` / `Settings`: user-movable, position + size remembered. pub fn should_remember(mode: SurfaceMode) -> bool { - matches!( - mode, - SurfaceMode::TrayPanel | SurfaceMode::PopOut | SurfaceMode::Settings - ) + matches!(mode, SurfaceMode::PopOut | SurfaceMode::Settings) } fn load_file() -> GeometryFile { @@ -154,66 +124,6 @@ pub fn save_entry(key: &str, geometry: StoredGeometry) { } } -/// Forget the geometry stored under an arbitrary key. No-op (and no write) -/// when nothing is stored there. -pub fn remove_entry(key: &str) { - let mut file = load_file(); - if file.entries.remove(key).is_none() { - return; - } - if let Err(err) = save_file(&file) { - tracing::warn!(target: "codexbar::geometry", %err, "failed to persist geometry"); - } -} - -/// Legacy key the flyout size was stored under before it became a dedicated -/// window: the old `SurfaceMode::TrayPanel` shared-window geometry entry. -const LEGACY_FLYOUT_SIZE_KEY: &str = "trayPanel"; - -/// Resolve a size-only entry from an in-memory [`GeometryFile`]: prefer the -/// new `size_entries` map, falling back to migrating a pre-existing -/// `"trayPanel"` [`StoredGeometry`] width/height (from before the flyout was -/// split into its own window) so upgrading users keep their remembered size -/// instead of it silently resetting to the default. Pure/side-effect-free so -/// it can be unit-tested without touching disk; [`load_size`] is the -/// disk-backed wrapper that also persists the migrated value. -fn resolve_size(file: &GeometryFile, key: &str) -> Option { - if let Some(size) = file.size_entries.get(key).copied() { - return Some(size); - } - if key != LEGACY_FLYOUT_SIZE_KEY { - let legacy = file.entries.get(LEGACY_FLYOUT_SIZE_KEY)?; - let (width, height) = (legacy.width?, legacy.height?); - return Some(StoredSize { width, height }); - } - None -} - -/// Look up a remembered size-only entry (e.g. the flyout window's -/// user-chosen width/height). See [`resolve_size`] for the migration -/// fallback; a migrated value is re-persisted under the new key so this -/// lookup path is only taken once (the legacy entry is left in place — -/// harmless, since nothing reads `entries["trayPanel"]` as a position -/// anymore). -pub fn load_size(key: &str) -> Option { - let file = load_file(); - let size = resolve_size(&file, key)?; - if !file.size_entries.contains_key(key) { - save_size(key, size); - } - Some(size) -} - -/// Persist a size-only entry under an arbitrary key. -pub fn save_size(key: &str, size: StoredSize) { - let mut file = load_file(); - file.version = GEOMETRY_VERSION; - file.size_entries.insert(key.to_string(), size); - if let Err(err) = save_file(&file) { - tracing::warn!(target: "codexbar::geometry", %err, "failed to persist size"); - } -} - #[cfg(test)] mod tests { use super::*; @@ -225,9 +135,18 @@ mod tests { } #[test] - fn tray_panel_is_remembered_for_size() { - // The flyout persists its size (position is always re-anchored). - assert!(should_remember(SurfaceMode::TrayPanel)); + fn tray_panel_is_not_remembered() { + assert!(!should_remember(SurfaceMode::TrayPanel)); + } + + #[test] + fn old_flyout_size_entries_are_dropped_on_the_next_save() { + let json = r#"{"version":1,"entries":{"settings":{"x":10,"y":20}},"size_entries":{"flyoutLogical":{"width":700,"height":700}}}"#; + let file: GeometryFile = serde_json::from_str(json).unwrap(); + assert_eq!( + serde_json::to_string(&file).unwrap(), + r#"{"version":1,"entries":{"settings":{"x":10,"y":20}}}"# + ); } #[test] @@ -309,101 +228,4 @@ mod tests { assert_eq!(entry.width, None); assert_eq!(entry.height, None); } - - #[test] - fn stored_size_round_trips_without_position_fields() { - let mut f = GeometryFile::default(); - f.size_entries.insert( - "flyout".into(), - StoredSize { - width: 400, - height: 820, - }, - ); - let json = serde_json::to_string(&f).unwrap(); - // The size-only entry must never carry x/y — that's the whole point - // of keeping it out of `entries: BTreeMap`. - assert!(!json.contains("\"x\"")); - assert!(!json.contains("\"y\"")); - let parsed: GeometryFile = serde_json::from_str(&json).unwrap(); - let entry = parsed.size_entries.get("flyout").unwrap(); - assert_eq!(entry.width, 400); - assert_eq!(entry.height, 820); - } - - #[test] - fn resolve_size_prefers_new_key_over_legacy() { - let mut file = GeometryFile::default(); - file.size_entries.insert( - "flyout".into(), - StoredSize { - width: 500, - height: 900, - }, - ); - file.entries.insert( - LEGACY_FLYOUT_SIZE_KEY.into(), - StoredGeometry { - x: 0, - y: 0, - width: Some(640), - height: Some(720), - }, - ); - - let resolved = resolve_size(&file, "flyout").expect("size present"); - assert_eq!(resolved.width, 500); - assert_eq!(resolved.height, 900); - } - - #[test] - fn resolve_size_migrates_legacy_tray_panel_geometry_when_no_new_entry() { - // Simulates an upgrading user: pre-refactor size lived under the - // `SurfaceMode::TrayPanel` shared-window geometry key. - let mut file = GeometryFile::default(); - file.entries.insert( - LEGACY_FLYOUT_SIZE_KEY.into(), - StoredGeometry { - x: 0, - y: 0, - width: Some(640), - height: Some(720), - }, - ); - - let resolved = resolve_size(&file, "flyout").expect("legacy size migrates"); - assert_eq!(resolved.width, 640); - assert_eq!(resolved.height, 720); - } - - #[test] - fn resolve_size_ignores_legacy_entry_missing_width_or_height() { - let mut file = GeometryFile::default(); - file.entries.insert( - LEGACY_FLYOUT_SIZE_KEY.into(), - StoredGeometry { - x: 0, - y: 0, - width: Some(640), - height: None, - }, - ); - - assert!(resolve_size(&file, "flyout").is_none()); - } - - #[test] - fn resolve_size_returns_none_when_nothing_stored() { - let file = GeometryFile::default(); - assert!(resolve_size(&file, "flyout").is_none()); - } - - #[test] - fn resolve_size_for_legacy_key_itself_does_not_self_migrate() { - // Looking up the legacy key directly should only consult - // `size_entries` (the `key != LEGACY_FLYOUT_SIZE_KEY` guard) — no - // infinite fallback to itself. - let file = GeometryFile::default(); - assert!(resolve_size(&file, LEGACY_FLYOUT_SIZE_KEY).is_none()); - } } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index d3f9cbd4d9..145962c8c3 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -212,9 +212,6 @@ fn main() { commands::open_settings_window, commands::open_flyout_window, commands::close_settings_window, - commands::set_flyout_size, - commands::flyout_stored_size, - commands::reset_flyout_position, commands::get_current_surface_state, commands::refresh_providers, commands::refresh_providers_if_stale, @@ -304,6 +301,7 @@ fn main() { commands::register_global_shortcut, commands::unregister_global_shortcut, commands::get_work_area_rect, + commands::get_system_accent_color, commands::play_notification_sound, commands::open_external_url, commands::reanchor_tray_panel, @@ -401,11 +399,6 @@ fn main() { { return; } - // Gesture guard: ignore blur while a resize-grip drag or - // HTML5 drag-reorder is running its Win32/OLE modal loop. - // Windows produces a spurious Focused(false) the instant - // such a loop starts even though the user never left the - // window; see AppState::begin_gesture_blur_guard. if let Some(st) = window.app_handle().try_state::>() && st .lock() diff --git a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs index 8a79e311c0..7e0561c9fe 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs @@ -168,25 +168,65 @@ unsafe extern "system" fn borderless_subclass_proc( } } +#[cfg(windows)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Chrome { + Dark, + /// Keeps `WS_THICKFRAME` so the native resize affordance still works. + DarkResizable, + /// The erase color comes from the builder's `background_color`, which + /// tao paints on `WM_ERASEBKGND`. + LightPanel, +} + +#[cfg(windows)] +const DWMWCP_ROUND: u32 = 2; + +/// Win32 COLORREF (`0x00BBGGRR`). +#[cfg(windows)] +const fn colorref(r: u8, g: u8, b: u8) -> u32 { + ((b as u32) << 16) | ((g as u32) << 8) | r as u32 +} + +#[cfg(windows)] +const PANEL_HAIRLINE: u32 = colorref(0x8A, 0x8B, 0x8E); + +#[cfg(windows)] +impl Chrome { + fn keeps_resize_frame(self) -> bool { + self == Self::DarkResizable + } + + fn corner_preference(self) -> Option { + (self == Self::LightPanel).then_some(DWMWCP_ROUND) + } + + fn border_color(self) -> Option { + (self == Self::LightPanel).then_some(PANEL_HAIRLINE) + } +} + /// Eliminate the DWM caption bar by subclassing the window to zero the /// non-client area. Safe to call on multiple windows — each gets its /// own subclass via `SetWindowSubclass`. -/// -/// When `resizable` is true, `WS_THICKFRAME` is preserved so the native -/// resize affordance still works. #[cfg(windows)] pub fn force_dark_caption(win: &tauri::WebviewWindow) { - force_dark_caption_inner(win, false); + apply_chrome(win, Chrome::Dark); } /// Same as [`force_dark_caption`] but keeps the resize frame. #[cfg(windows)] pub fn force_dark_caption_resizable(win: &tauri::WebviewWindow) { - force_dark_caption_inner(win, true); + apply_chrome(win, Chrome::DarkResizable); } #[cfg(windows)] -fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { +pub fn light_panel_chrome(win: &tauri::WebviewWindow) { + apply_chrome(win, Chrome::LightPanel); +} + +#[cfg(windows)] +fn apply_chrome(win: &tauri::WebviewWindow, chrome: Chrome) { use raw_window_handle::HasWindowHandle; let Ok(handle) = win.window_handle() else { @@ -224,6 +264,28 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { ); tracing::info!("dwm: dark_mode={r1:#x} caption_color={r2:#x}"); + const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 33; + const DWMWA_BORDER_COLOR: u32 = 34; + // Windows 10 rejects both attributes and keeps square corners. + if let Some(corner) = chrome.corner_preference() { + let r = DwmSetWindowAttribute( + hwnd, + DWMWA_WINDOW_CORNER_PREFERENCE, + &raw const corner as *const c_void, + 4, + ); + tracing::info!("dwm: corner_preference={r:#x}"); + } + if let Some(border) = chrome.border_color() { + let r = DwmSetWindowAttribute( + hwnd, + DWMWA_BORDER_COLOR, + &raw const border as *const c_void, + 4, + ); + tracing::info!("dwm: border_color={r:#x}"); + } + // Extend DWM frame fully into client area let margins = Margins { left: -1, @@ -250,6 +312,7 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { const WS_CAPTION: isize = 0x00C00000; const WS_THICKFRAME: isize = 0x00040000; let style = GetWindowLongPtrW(hwnd, GWL_STYLE); + let keep_resize = chrome.keeps_resize_frame(); let new_style = if keep_resize { style & !WS_CAPTION } else { @@ -288,3 +351,34 @@ pub fn force_dark_caption(_win: &tauri::WebviewWindow) {} #[cfg(not(windows))] pub fn force_dark_caption_resizable(_win: &tauri::WebviewWindow) {} + +#[cfg(not(windows))] +pub fn light_panel_chrome(_win: &tauri::WebviewWindow) {} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn colorref_is_bgr() { + assert_eq!(colorref(0xDE, 0xDE, 0xE2), 0x00E2_DEDE); + assert_eq!(colorref(0x8A, 0x8B, 0x8E), 0x008E_8B8A); + } + + #[test] + fn only_the_light_panel_rounds_and_recolors_its_border() { + assert_eq!(Chrome::LightPanel.corner_preference(), Some(2)); + assert_eq!(Chrome::LightPanel.border_color(), Some(0x008E_8B8A)); + assert_eq!(Chrome::Dark.corner_preference(), None); + assert_eq!(Chrome::Dark.border_color(), None); + assert_eq!(Chrome::DarkResizable.corner_preference(), None); + assert_eq!(Chrome::DarkResizable.border_color(), None); + } + + #[test] + fn only_the_resizable_dark_chrome_keeps_the_resize_frame() { + assert!(!Chrome::LightPanel.keeps_resize_frame()); + assert!(!Chrome::Dark.keeps_resize_frame()); + assert!(Chrome::DarkResizable.keeps_resize_frame()); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs deleted file mode 100644 index 30df73a666..0000000000 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Where the user put the tray-panel flyout. -//! -//! The flyout opens anchored to the tray icon. Once the user drags it -//! somewhere else, that spot is remembered, and every later open and every -//! content-driven resize keeps the panel there instead of snapping it back to -//! the tray. Double-clicking the panel's move handle forgets the spot. -//! -//! A spot is recorded only when a Win32 move/size loop ends -//! (`WM_EXITSIZEMOVE`). Windows runs that loop for user drags of the window -//! or its frame, never for `SetWindowPos`, so the flyout's own re-anchoring -//! can't be mistaken for a user move. - -use crate::geometry_store::{self, StoredGeometry}; -use crate::window_positioner::{self, PanelSize, Rect}; - -/// Geometry-store key for the remembered flyout position. Lives in the -/// position `entries` map, separate from the size-only `"flyout"` entry. -const FLYOUT_POSITION_KEY: &str = "flyout"; - -/// The position the user dragged the flyout to, if any (physical px). -pub fn stored_position() -> Option<(i32, i32)> { - geometry_store::load_entry(FLYOUT_POSITION_KEY).map(|geometry| (geometry.x, geometry.y)) -} - -fn save_position(x: i32, y: i32) { - geometry_store::save_entry( - FLYOUT_POSITION_KEY, - StoredGeometry { - x, - y, - width: None, - height: None, - }, - ); -} - -/// Forget the user's spot so the flyout anchors to the tray again. -pub fn clear_position() { - geometry_store::remove_entry(FLYOUT_POSITION_KEY); -} - -/// The remembered position, clamped into the work area the panel overlaps -/// most (or the nearest one when a monitor layout change left it on none), so -/// the panel can't end up off-screen. `None` when the user never moved the -/// flyout. -pub fn placed_position(window: &tauri::WebviewWindow) -> Option<(i32, i32)> { - let (x, y) = stored_position()?; - let outer = window.outer_size().ok()?; - let panel = Rect { - x, - y, - width: outer.width, - height: outer.height, - }; - let work_areas: Vec = window - .available_monitors() - .ok()? - .iter() - .map(super::geometry::monitor_work_area_rect) - .collect(); - let work_area = best_work_area(&work_areas, &panel)?; - // The size is already physical, so clamp with a 1.0 scale. - Some(window_positioner::clamp_position_to_work_area( - x, - y, - &work_area, - &PanelSize { - width: outer.width, - height: outer.height, - }, - 1.0, - )) -} - -/// The work area `panel` overlaps most; with no overlap, the one whose center -/// is nearest. -fn best_work_area(work_areas: &[Rect], panel: &Rect) -> Option { - work_areas.iter().copied().max_by_key(|area| { - ( - overlap_area(area, panel), - std::cmp::Reverse(center_distance(area, panel)), - ) - }) -} - -fn overlap_area(a: &Rect, b: &Rect) -> i64 { - let width = (right(a).min(right(b)) - i64::from(a.x).max(i64::from(b.x))).max(0); - let height = (bottom(a).min(bottom(b)) - i64::from(a.y).max(i64::from(b.y))).max(0); - width * height -} - -fn center_distance(a: &Rect, b: &Rect) -> i64 { - // Doubled centers keep the math in integers. - let dx = (i64::from(a.x) + right(a)) - (i64::from(b.x) + right(b)); - let dy = (i64::from(a.y) + bottom(a)) - (i64::from(b.y) + bottom(b)); - dx * dx + dy * dy -} - -fn right(rect: &Rect) -> i64 { - i64::from(rect.x) + i64::from(rect.width) -} - -fn bottom(rect: &Rect) -> i64 { - i64::from(rect.y) + i64::from(rect.height) -} - -/// Whether a mouse button is held on the flyout itself. -/// -/// Windows moves focus off the WebView the moment a move or resize starts on -/// the window frame, so the flyout sees a blur before the gesture even -/// begins. A blur while the user is pressing on the panel itself is that -/// gesture, not a click somewhere else. The window under the cursor is -/// checked, not the panel's bounds, so a click on another topmost window -/// covering the panel still dismisses it. -#[cfg(windows)] -pub fn pointer_pressed_inside(window: &tauri::Window) -> bool { - const GA_ROOT: u32 = 2; - if !mouse_button_down() { - return false; - } - let Some(flyout) = super::activation::root_hwnd(window) else { - return false; - }; - let mut cursor = Win32Point::default(); - // SAFETY: GetCursorPos writes the caller-owned point; WindowFromPoint and - // GetAncestor only read the window tree. - unsafe { - if GetCursorPos(&mut cursor) == 0 { - return false; - } - let under_cursor = WindowFromPoint(cursor); - under_cursor != 0 && GetAncestor(under_cursor, GA_ROOT) == flyout - } -} - -#[cfg(not(windows))] -pub fn pointer_pressed_inside(_window: &tauri::Window) -> bool { - false -} - -/// Outer window bounds in physical px, as `GetWindowRect` reports them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct WindowBounds { - left: i32, - top: i32, - right: i32, - bottom: i32, -} - -/// One user move/size loop in progress. -#[derive(Debug, Clone, Copy)] -struct SizeMoveLoop { - start: WindowBounds, - /// The user dragged an edge (`WM_SIZING`) rather than the whole window. - /// Compared bounds can't tell: a move onto a monitor with another DPI - /// rescales the window too. - resizing: bool, -} - -/// The spot to remember after one user move/size loop, if any. -/// -/// Dragging the whole window places the flyout. A resize from the top or left -/// edge also moves the window; that only updates the spot once the user has -/// placed the flyout, so resizing a tray-anchored panel keeps it tray-anchored. -fn placement_after_size_move( - size_move: SizeMoveLoop, - end: WindowBounds, - already_placed: bool, -) -> Option<(i32, i32)> { - let start = size_move.start; - let moved = (start.left, start.top) != (end.left, end.top); - (moved && (already_placed || !size_move.resizing)).then_some((end.left, end.top)) -} - -/// Record user drags of `window` from now on. The subclass must be installed -/// on the thread that owns the window, so this hops to the main thread. -#[cfg(windows)] -pub fn track_user_moves(window: &tauri::WebviewWindow) { - let Some(hwnd) = super::activation::root_hwnd(window) else { - tracing::warn!("flyout_placement: no window handle; user moves won't be remembered"); - return; - }; - let scheduled = window.run_on_main_thread(move || { - // SAFETY: `hwnd` is the live top-level flyout window and this runs on - // its owning thread. The subclass proc forwards every message. - let installed = - unsafe { SetWindowSubclass(hwnd, size_move_subclass_proc, SIZE_MOVE_SUBCLASS_ID, 0) }; - if installed == 0 { - tracing::warn!("flyout_placement: SetWindowSubclass failed"); - } - }); - if let Err(error) = scheduled { - tracing::warn!(%error, "flyout_placement: couldn't schedule move tracking"); - } -} - -#[cfg(not(windows))] -pub fn track_user_moves(_window: &tauri::WebviewWindow) {} - -/// Whether the user is dragging the flyout or one of its edges right now. -#[cfg(windows)] -pub fn user_move_in_progress() -> bool { - SIZE_MOVE.lock().is_ok_and(|size_move| size_move.is_some()) -} - -#[cfg(not(windows))] -pub fn user_move_in_progress() -> bool { - false -} - -#[cfg(windows)] -const SIZE_MOVE_SUBCLASS_ID: usize = 0xC0DE_F1E0; -#[cfg(windows)] -const WM_ENTERSIZEMOVE: u32 = 0x0231; -#[cfg(windows)] -const WM_EXITSIZEMOVE: u32 = 0x0232; -#[cfg(windows)] -const WM_SIZING: u32 = 0x0214; -#[cfg(windows)] -const WM_NCDESTROY: u32 = 0x0082; - -/// The move/size loop in progress. There is one flyout window, so one slot -/// is enough. -#[cfg(windows)] -static SIZE_MOVE: std::sync::Mutex> = std::sync::Mutex::new(None); - -#[cfg(windows)] -unsafe extern "system" fn size_move_subclass_proc( - hwnd: isize, - msg: u32, - wparam: usize, - lparam: isize, - _id: usize, - _data: usize, -) -> isize { - match msg { - WM_ENTERSIZEMOVE => { - if let Ok(mut size_move) = SIZE_MOVE.lock() { - *size_move = window_bounds(hwnd).map(|start| SizeMoveLoop { - start, - resizing: false, - }); - } - } - WM_SIZING => { - if let Ok(mut size_move) = SIZE_MOVE.lock() - && let Some(size_move) = size_move.as_mut() - { - size_move.resizing = true; - } - } - WM_NCDESTROY => { - if let Ok(mut size_move) = SIZE_MOVE.lock() { - *size_move = None; - } - // SAFETY: removes this subclass from the window being destroyed, - // on its owning thread. - unsafe { - RemoveWindowSubclass(hwnd, size_move_subclass_proc, SIZE_MOVE_SUBCLASS_ID); - } - } - WM_EXITSIZEMOVE => { - let size_move = SIZE_MOVE - .lock() - .ok() - .and_then(|mut size_move| size_move.take()); - if let (Some(size_move), Some(end)) = (size_move, window_bounds(hwnd)) - && let Some((x, y)) = - placement_after_size_move(size_move, end, stored_position().is_some()) - { - save_position(x, y); - } - } - _ => {} - } - // SAFETY: forwards the message this subclass received, unchanged. - unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) } -} - -#[cfg(windows)] -fn window_bounds(hwnd: isize) -> Option { - let mut rect = Win32Rect::default(); - // SAFETY: `hwnd` is the live window the subclass is attached to and - // `rect` is a caller-owned out-parameter. - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return None; - } - Some(WindowBounds { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - }) -} - -#[cfg(windows)] -fn mouse_button_down() -> bool { - // Physical buttons: with swapped buttons the primary one is VK_RBUTTON, - // so check both. - const VK_LBUTTON: i32 = 0x01; - const VK_RBUTTON: i32 = 0x02; - // SAFETY: GetAsyncKeyState only reads global input state. A negative - // result means the key is down. - [VK_LBUTTON, VK_RBUTTON] - .into_iter() - .any(|key| unsafe { GetAsyncKeyState(key) } < 0) -} - -#[cfg(windows)] -#[repr(C)] -#[derive(Default)] -struct Win32Point { - x: i32, - y: i32, -} - -#[cfg(windows)] -#[repr(C)] -#[derive(Default)] -struct Win32Rect { - left: i32, - top: i32, - right: i32, - bottom: i32, -} - -#[cfg(windows)] -#[link(name = "user32")] -// SAFETY: FFI declarations for user32 calls; every call site passes the live -// flyout window handle or caller-owned out-parameters. -unsafe extern "system" { - fn GetAsyncKeyState(key: i32) -> i16; - fn GetWindowRect(hwnd: isize, rect: *mut Win32Rect) -> i32; - fn GetCursorPos(point: *mut Win32Point) -> i32; - fn WindowFromPoint(point: Win32Point) -> isize; - fn GetAncestor(hwnd: isize, flags: u32) -> isize; -} - -#[cfg(windows)] -#[link(name = "comctl32")] -// SAFETY: FFI declarations for the comctl32 subclass API; called with the -// live flyout window handle and a `'static` subclass procedure. -unsafe extern "system" { - fn SetWindowSubclass( - hwnd: isize, - proc: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize, - id: usize, - data: usize, - ) -> i32; - fn DefSubclassProc(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize; - fn RemoveWindowSubclass( - hwnd: isize, - proc: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize, - id: usize, - ) -> i32; -} - -#[cfg(test)] -mod tests { - use super::*; - - fn bounds(left: i32, top: i32, width: i32, height: i32) -> WindowBounds { - WindowBounds { - left, - top, - right: left + width, - bottom: top + height, - } - } - - fn moved(start: WindowBounds) -> SizeMoveLoop { - SizeMoveLoop { - start, - resizing: false, - } - } - - fn resized(start: WindowBounds) -> SizeMoveLoop { - SizeMoveLoop { - start, - resizing: true, - } - } - - #[test] - fn dragging_the_window_places_the_flyout() { - let start = bounds(1936, 511, 300, 873); - let end = bounds(400, 200, 300, 873); - assert_eq!( - placement_after_size_move(moved(start), end, false), - Some((400, 200)) - ); - } - - #[test] - fn dragging_onto_a_monitor_with_another_dpi_still_places_the_flyout() { - // Windows rescales the window on the way (100% -> 225%). - let start = bounds(1808, 611, 340, 773); - let end = bounds(600, -1500, 765, 1739); - assert_eq!( - placement_after_size_move(moved(start), end, false), - Some((600, -1500)) - ); - } - - #[test] - fn resizing_a_tray_anchored_flyout_from_the_top_left_keeps_it_tray_anchored() { - let start = bounds(1936, 511, 300, 873); - let end = bounds(1836, 411, 400, 973); - assert_eq!(placement_after_size_move(resized(start), end, false), None); - } - - #[test] - fn resizing_a_placed_flyout_from_the_top_left_follows_its_new_corner() { - let start = bounds(400, 200, 300, 873); - let end = bounds(300, 100, 400, 973); - assert_eq!( - placement_after_size_move(resized(start), end, true), - Some((300, 100)) - ); - } - - #[test] - fn resizing_from_the_bottom_right_never_records_a_spot() { - let start = bounds(400, 200, 300, 873); - let end = bounds(400, 200, 360, 900); - assert_eq!(placement_after_size_move(resized(start), end, true), None); - assert_eq!(placement_after_size_move(resized(start), end, false), None); - } - - #[test] - fn a_drag_that_ends_where_it_started_records_nothing() { - let start = bounds(400, 200, 300, 873); - assert_eq!(placement_after_size_move(moved(start), start, false), None); - } - - fn area(x: i32, y: i32, width: u32, height: u32) -> Rect { - Rect { - x, - y, - width, - height, - } - } - - // A stacked layout: primary monitor below, a taller one above. - fn stacked_work_areas() -> Vec { - vec![area(0, 0, 2560, 1392), area(0, -2560, 3840, 2452)] - } - - #[test] - fn a_panel_straddling_two_monitors_belongs_to_the_one_it_overlaps_most() { - let panel = area(800, -900, 400, 1000); - let chosen = best_work_area(&stacked_work_areas(), &panel).expect("work area"); - assert_eq!((chosen.x, chosen.y), (0, -2560)); - } - - #[test] - fn a_panel_whose_corner_hangs_off_screen_stays_on_its_monitor() { - // The top-left corner is left of every monitor. - let panel = area(-40, 300, 400, 900); - let chosen = best_work_area(&stacked_work_areas(), &panel).expect("work area"); - assert_eq!((chosen.x, chosen.y), (0, 0)); - } - - #[test] - fn a_panel_on_a_monitor_that_is_gone_moves_to_the_nearest_one() { - // It was on a monitor right of the primary that has been unplugged. - let panel = area(3000, 200, 400, 900); - let chosen = best_work_area(&stacked_work_areas(), &panel).expect("work area"); - assert_eq!((chosen.x, chosen.y), (0, 0)); - } - - #[test] - fn no_monitors_means_no_placement() { - assert!(best_work_area(&[], &area(0, 0, 400, 900)).is_none()); - } -} diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 1b177f799a..1ca79f05c2 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -1,18 +1,8 @@ -//! Detached "Pop Out Dashboard" flyout window: a resizable, tray-anchored -//! panel with optional always-on-top behavior that auto-hides on click-outside. -//! //! Runs as an auxiliary Tauri window labeled `flyout`, independent of the //! `main` window's surface state machine. It is the only dashboard layout: //! tray left-click, "Pop Out Dashboard", the global shortcut, app launch and //! single-instance relaunch all open it. The legacy PopOut layout on `main` -//! is retired. It opens next to the tray until the user drags it elsewhere; -//! `super::flyout_placement` remembers that spot. -//! -//! Structurally modeled on `crate::floatbar` (self-contained module owning -//! its window + a `handle_window_event` hook dispatched from `main.rs` -//! before the `main`-window-only handling); the window itself is built with -//! `settings_window.rs`'s builder recipe (async open, manual DWM dark-caption -//! pass, `WebviewUrl::App` with a `?window=` query marker). +//! is retired. use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; @@ -22,24 +12,13 @@ use codexbar::settings::Settings; use tauri::{AppHandle, Manager, PhysicalPosition, WebviewUrl}; use super::activation::{self, Activation}; -use crate::geometry_store::{self, StoredSize}; use crate::state::AppState; use crate::surface::SurfaceMode; pub const FLYOUT_LABEL: &str = "flyout"; -/// Geometry-store key for the flyout's remembered SIZE in logical px (the -/// position is stored separately, and only after the user drags the flyout). -/// Kept as its own key (distinct from the legacy -/// `SurfaceMode::TrayPanel::as_str()` `"trayPanel"` key) — -/// `geometry_store::load_size` migrates a pre-existing `"trayPanel"` entry -/// into this key on first read, so upgrading users keep their remembered -/// flyout size. -/// -/// The earlier `"flyout"` size entry held physical px, which only fit the -/// monitor it was taken on; it is ignored, so a user who resized under it -/// gets the legacy `"trayPanel"` size, or else the auto-fit size, once. -const FLYOUT_SIZE_KEY: &str = "flyoutLogical"; +/// The window paints this before the page loads and on every erase. +const PANEL_FILL: tauri::utils::config::Color = tauri::utils::config::Color(0xDE, 0xDE, 0xE2, 0xFF); /// Same window used to close a same-click blur-dismiss/reopen race as the /// pre-split tray panel handling (formerly `shell::transition::handle_tray_panel_click`, @@ -55,27 +34,11 @@ const RECENTLY_SHOWN_GRACE: Duration = Duration::from_millis(500); /// when it loses focus, for automation flows that need it to stay visible. static KEEP_OPEN_ON_BLUR: AtomicBool = AtomicBool::new(false); -/// Set by a DPI change of the flyout; the next `Resized` (the rescale itself) -/// re-clamps a user-placed flyout at its new size. -static RECLAMP_AFTER_RESCALE: AtomicBool = AtomicBool::new(false); - /// Keep the flyout open on focus loss for the rest of this process. pub fn keep_open_on_blur() { KEEP_OPEN_ON_BLUR.store(true, Ordering::Relaxed); } -/// Read the remembered flyout size, if any (migrating a legacy -/// `"trayPanel"`-keyed size on first read — see `geometry_store::load_size`). -pub fn stored_size() -> Option<(u32, u32)> { - geometry_store::load_size(FLYOUT_SIZE_KEY).map(|size| (size.width, size.height)) -} - -/// Persist a user-chosen flyout size in logical px. Size-only — no -/// fabricated position. -pub fn save_stored_size(width: u32, height: u32) { - geometry_store::save_size(FLYOUT_SIZE_KEY, StoredSize { width, height }); -} - /// Whether the flyout window currently exists and is visible. Canonical /// replacement for the pre-split `surface_machine.current() == TrayPanel` /// check, now that the flyout is not a state of the shared machine. @@ -148,24 +111,21 @@ fn open_with_anchor( // from, rather than duplicating these values as independent constants // that could silently drift from `surface.rs`. let props = SurfaceMode::TrayPanel.window_properties(); - let (width, height) = stored_size() - .map(|(w, h)| (w as f64, h as f64)) - .unwrap_or((props.width, props.height)); - let url = WebviewUrl::App("index.html?window=flyout".into()); - let mut builder = tauri::WebviewWindowBuilder::new(app, FLYOUT_LABEL, url) + let builder = tauri::WebviewWindowBuilder::new(app, FLYOUT_LABEL, url) .title("CodexBar") - .inner_size(width, height) + .inner_size(props.width, props.height) .decorations(props.decorations) .shadow(false) .resizable(props.resizable) - // No maximize box: dragging the move strip to a screen edge must not - // snap or maximize the panel. .maximizable(false) .always_on_top(settings.tray_panel_always_on_top) .skip_taskbar(props.skip_taskbar) + // WebView2 shares `prefers-color-scheme` across the profile, so the + // flyout stays pinned dark like the other windows. .theme(Some(tauri::Theme::Dark)) + .background_color(PANEL_FILL) // CRITICAL: dynamically-built windows default to drag-drop ENABLED, // which intercepts the HTML5 draggable events the provider grid's // drag-reorder (ProviderGrid.tsx) relies on — see `main`'s @@ -177,19 +137,12 @@ fn open_with_anchor( // focus. .focused(false) .visible(false); - if let (Some(min_w), Some(min_h)) = (props.min_width, props.min_height) { - builder = builder.min_inner_size(min_w, min_h); - } let win = builder.build().map_err(|e| e.to_string())?; apply_window_always_on_top(&win, settings.tray_panel_always_on_top)?; - // Force DWM caption dark; keep WS_THICKFRAME (resizable) like the - // Settings window. - super::dwm::force_dark_caption_resizable(&win); - super::flyout_placement::track_user_moves(&win); + super::dwm::light_panel_chrome(&win); - let user_placed = position.is_none() && super::flyout_placement::stored_position().is_some(); - if cursor.is_some() || user_placed { + if cursor.is_some() { reanchor(app)?; } else if let Some((x, y)) = position.or_else(|| super::position::default_surface_position(app, SurfaceMode::TrayPanel)) @@ -331,11 +284,6 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - { return true; } - // A press on the panel's own frame starts a native move/resize, - // which blurs the WebView before the gesture begins. - if super::flyout_placement::pointer_pressed_inside(window) { - return true; - } let Some(st) = app.try_state::>() else { return true; }; @@ -364,32 +312,6 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } true } - // Size persistence is entirely frontend-driven (genuine user - // drag-resizes call `set_flyout_size`, auto-fit resizes never do) — - // mirrors `shell::position::remember_current_geometry_if_eligible` - // skipping TrayPanel for the same reason on the old shared window. - // User positions are recorded by `flyout_placement` when the native - // move/size loop ends, not per event. - tauri::WindowEvent::Moved(_) => true, - tauri::WindowEvent::ScaleFactorChanged { .. } => { - // The window is rescaled right after this event; clamp on that - // `Resized`, when the new size is in place. - RECLAMP_AFTER_RESCALE.store(true, Ordering::Relaxed); - true - } - tauri::WindowEvent::Resized(_) => { - // Landing on a monitor with another DPI rescales a placed flyout - // after it was clamped at the old size, which can push it past the - // work area. Not while the user is still dragging it. - if RECLAMP_AFTER_RESCALE.swap(false, Ordering::Relaxed) - && super::flyout_placement::stored_position().is_some() - && !super::flyout_placement::user_move_in_progress() - { - // Best-effort; a failure leaves the panel where Windows put it. - let _reclamp = reanchor(app); - } - true - } tauri::WindowEvent::CloseRequested { api, .. } => { // Hide-not-close on a native close request (Alt+F4-equivalent from // a screen reader): the flyout survives so it can be reopened without @@ -403,18 +325,6 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } } -/// Forget where the user dragged the flyout and anchor it to the tray again. -pub fn reset_position(app: &AppHandle) -> Result<(), String> { - super::flyout_placement::clear_position(); - reanchor(app) -} - -/// Reposition the flyout at the user's spot, or else its captured cursor or -/// system-tray anchor, -/// using the window's CURRENT logical size (after a -/// frontend-driven resize). Canonical anchor-math implementation for the -/// flyout window; the `reanchor_tray_panel` Tauri command -/// (`commands/system.rs`) is a thin retarget onto this function. pub fn reanchor(app: &AppHandle) -> Result<(), String> { use crate::window_positioner::{PanelSize, Rect}; @@ -422,20 +332,6 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { .get_webview_window(FLYOUT_LABEL) .ok_or_else(|| "flyout window unavailable".to_string())?; - // Never move the window out from under the user's drag; the loop's end - // records where it landed. - if super::flyout_placement::user_move_in_progress() { - return Ok(()); - } - - // A flyout the user dragged elsewhere stays there; only its on-screen - // clamp follows the current size. - if let Some((x, y)) = super::flyout_placement::placed_position(&window) { - // Best-effort, like the anchored set_position below. - let _set_placed = window.set_position(PhysicalPosition::new(x, y)); - return Ok(()); - } - let scale = window.scale_factor().unwrap_or(1.0).max(1.0); let outer = window.outer_size().map_err(|e| e.to_string())?; @@ -528,34 +424,21 @@ mod tests { } #[test] - fn flyout_size_key_is_distinct_from_legacy_tray_panel_key() { - // The whole point of the migration in geometry_store::load_size is - // that this key differs from the legacy SurfaceMode::TrayPanel key - // ("trayPanel") — otherwise there'd be nothing to migrate FROM. - assert_ne!(FLYOUT_SIZE_KEY, SurfaceMode::TrayPanel.as_str()); - } - - #[test] - fn flyout_size_key_skips_the_old_physical_px_entry() { - // "flyout" sizes were physical px; reading them as logical would open - // the panel scale-factor times too large on a scaled display. - assert_ne!(FLYOUT_SIZE_KEY, "flyout"); + fn panel_fill_matches_the_css_panel_background() { + let css = include_str!("../../../src/styles.css"); + let tauri::utils::config::Color(r, g, b, a) = PANEL_FILL; + assert_eq!(a, 0xFF); + assert!(css.contains(&format!("--mac-panel-bg: #{r:02x}{g:02x}{b:02x};"))); } #[test] fn tray_panel_window_properties_still_the_single_source_for_flyout_shape() { - // `open_or_focus`'s builder reads size/decorations/resizable/ - // skip_taskbar/min-size from - // `SurfaceMode::TrayPanel.window_properties()` directly (not - // independent duplicated constants) — this pins down the values that - // relationship depends on, so a change to `surface.rs` shows up here - // instead of silently drifting from what the flyout actually builds. let props = SurfaceMode::TrayPanel.window_properties(); - assert_eq!(props.width, 328.0); + assert_eq!(props.width, 310.0); assert_eq!(props.height, 776.0); - assert_eq!(props.min_width, Some(300.0)); - assert_eq!(props.min_height, Some(360.0)); - assert!(props.resizable); + assert_eq!(props.min_width, None); + assert_eq!(props.min_height, None); + assert!(!props.resizable); assert!(!props.always_on_top); assert!(props.skip_taskbar); assert!(!props.decorations); diff --git a/apps/desktop-tauri/src-tauri/src/shell/mod.rs b/apps/desktop-tauri/src-tauri/src/shell/mod.rs index e7dd22ed16..3741d5446d 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/mod.rs @@ -8,7 +8,6 @@ use crate::surface_target::SurfaceTarget; pub mod activation; pub(crate) mod dwm; -mod flyout_placement; pub mod flyout_window; mod geometry; mod position; diff --git a/apps/desktop-tauri/src-tauri/src/shell/position.rs b/apps/desktop-tauri/src-tauri/src/shell/position.rs index f5e8379950..57de0f925b 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/position.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/position.rs @@ -232,13 +232,7 @@ pub fn remember_current_geometry_if_eligible(window: &tauri::Window) { let guard = st.lock().unwrap(); guard.surface_machine.current() }; - // The TrayPanel flyout persists its size explicitly from the frontend (only - // on genuine user drag-resizes, never on its own auto-fit resizes), so skip - // the automatic capture here — otherwise an auto-fit resize would be saved - // and freeze the panel at that size. - if current_mode == SurfaceMode::TrayPanel - || !crate::geometry_store::should_remember(current_mode) - { + if !crate::geometry_store::should_remember(current_mode) { return; } diff --git a/apps/desktop-tauri/src-tauri/src/shell/tests.rs b/apps/desktop-tauri/src-tauri/src/shell/tests.rs index 80ddeb50cc..c7651ce69b 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/tests.rs @@ -939,10 +939,7 @@ fn popout_layout_size_uses_remembered_logical_geometry() { } #[test] -fn tray_panel_layout_uses_remembered_size() { - // The "Pop Out Dashboard" flyout now honors the user's remembered SIZE. - // (Position is still re-anchored above the tray via default_surface_position, - // which ignores the stored x/y — only the size is taken from geometry.) +fn tray_panel_layout_ignores_remembered_size() { let props = SurfaceMode::TrayPanel.window_properties(); let stored = crate::geometry_store::StoredGeometry { x: 0, @@ -953,5 +950,5 @@ fn tray_panel_layout_uses_remembered_size() { let size = logical_size_from_geometry(SurfaceMode::TrayPanel, &props, Some(stored)); - assert_eq!(size, (640.0, 720.0)); + assert_eq!(size, (310.0, 776.0)); } diff --git a/apps/desktop-tauri/src-tauri/src/shell/window.rs b/apps/desktop-tauri/src-tauri/src/shell/window.rs index 470e8c0187..0ae1237def 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/window.rs @@ -65,17 +65,6 @@ pub fn apply_window_layout( } if props.visible { - // The flyout ("Pop Out Dashboard") window is sized entirely by the - // frontend (content auto-fit, or the user's remembered size applied - // on open via `flyout_window::open_or_focus` + `set_flyout_size`) — - // it is its own dedicated window now, never `main`, so this function - // (which only ever runs against `main`'s window; see callers in - // `shell/transition.rs`) needs no special-case for it. `main`'s - // surface machine only ever holds Hidden/PopOut/Settings, none of - // which race a frontend-owned size the way the old shared-window - // TrayPanel mode did — so the backend is the sole size-setter here, - // unconditionally, for every mode this function can be called with. - // // The canonical surface mode drives geometry restore — no brittle // shape-matching of WindowProperties. logical_size_from_geometry // falls back to the mode's default size for non-remembered modes. diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 07191d57c4..32e438e520 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -167,7 +167,7 @@ pub struct AppState { /// One-shot permission for frontend layout code to reveal a newly opened /// flyout, carrying whether the revealed window may take focus. pub flyout_reveal_pending: Option, - /// Active while a user gesture (resize drag, HTML5 drag-reorder) is + /// Active while a user gesture (the provider tab HTML5 drag-reorder) is /// running a Win32 modal loop that transiently steals focus from the /// WebView2 child. `(began, until)` — `until` is the hard expiry; /// `began` lets a genuine refocus clear the guard early once the @@ -276,9 +276,6 @@ impl AppState { self.flyout_reveal_pending.take() } - /// Arm the gesture blur guard for 15s. Called when the frontend reports - /// a resize-grip press or a drag-reorder mousedown is about to start a - /// Win32/OLE modal loop that will transiently blur the window. pub fn begin_gesture_blur_guard(&mut self, now: std::time::Instant) { self.gesture_blur_guard = Some((now, now + std::time::Duration::from_secs(15))); } diff --git a/apps/desktop-tauri/src-tauri/src/surface.rs b/apps/desktop-tauri/src-tauri/src/surface.rs index 5ce867660a..9395af04ba 100644 --- a/apps/desktop-tauri/src-tauri/src/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/surface.rs @@ -53,11 +53,11 @@ impl SurfaceMode { Self::TrayPanel => WindowProperties { visible: true, decorations: false, - resizable: true, - width: 328.0, + resizable: false, + width: 310.0, height: 776.0, - min_width: Some(300.0), - min_height: Some(360.0), + min_width: None, + min_height: None, always_on_top: false, blur_dismiss: true, skip_taskbar: true, @@ -268,21 +268,20 @@ mod tests { #[test] fn tray_panel_properties() { let props = SurfaceMode::TrayPanel.window_properties(); - assert_eq!(props.width, 328.0); + assert_eq!(props.width, 310.0); assert_eq!(props.height, 776.0); } #[test] - fn tray_panel_is_resizable_blur_dismiss_flyout() { + fn tray_panel_is_fixed_size_blur_dismiss_flyout() { let props = SurfaceMode::TrayPanel.window_properties(); - // "Pop Out Dashboard" flyout: resizable, anchored, auto-hide, no taskbar. - assert!(props.resizable); + assert!(!props.resizable); assert!(props.blur_dismiss); assert!(!props.always_on_top); assert!(props.skip_taskbar); assert!(!props.decorations); - assert_eq!(props.min_width, Some(300.0)); - assert_eq!(props.min_height, Some(360.0)); + assert_eq!(props.min_width, None); + assert_eq!(props.min_height, None); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index ce7e8bf1b2..c1a50e9ac1 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -599,11 +599,8 @@ mod tests { let action = resolve_menu_action("pop_out").expect("pop_out action"); assert!(matches!(action, MenuAction::OpenFlyout)); - // SurfaceMode::TrayPanel is retained purely as a data key (geometry - // key / window_properties source / panel-size reference) for the - // flyout window's builder — the properties themselves are unchanged. let props = SurfaceMode::TrayPanel.window_properties(); - assert!(props.resizable && props.blur_dismiss && props.skip_taskbar); + assert!(!props.resizable && props.blur_dismiss && props.skip_taskbar); } #[test] diff --git a/apps/desktop-tauri/src/components/MenuSurface.test.tsx b/apps/desktop-tauri/src/components/MenuSurface.test.tsx index 041df7ec2e..439929a646 100644 --- a/apps/desktop-tauri/src/components/MenuSurface.test.tsx +++ b/apps/desktop-tauri/src/components/MenuSurface.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -18,7 +18,7 @@ vi.mock("@tauri-apps/api/event", () => eventMocks); import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; -import { MenuSummary } from "./MenuSurface"; +import MenuSurface, { MenuSummary, type MenuFooterRow } from "./MenuSurface"; function renderSummary(total: number) { return render( @@ -54,3 +54,129 @@ describe("MenuSummary", () => { expect(await screen.findByText("2 providers")).toBeInTheDocument(); }); }); + +function renderFooter(groups: MenuFooterRow[][]) { + return render( + + +
cards
+
+
, + ); +} + +describe("MenuSurface footer", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle()); + eventMocks.listen.mockResolvedValue(() => {}); + }); + + it("renders the Mac footer groups in order, each after a separator", async () => { + const noop = vi.fn(); + renderFooter([ + [ + { id: "switchAccount", label: "Switch Account...", onClick: noop }, + { id: "dashboard", label: "Usage Dashboard", onClick: noop }, + { id: "statusPage", label: "Status Page", onClick: noop }, + ], + [ + { id: "refresh", label: "Refresh", shortcut: "Ctrl+R", onClick: noop }, + { id: "settings", label: "Settings...", shortcut: "Ctrl+,", onClick: noop }, + { id: "about", label: "About CodexBar (v0.70.0)", onClick: noop }, + { id: "quit", label: "Quit", shortcut: "Ctrl+Q", onClick: noop }, + ], + ]); + + const nav = await screen.findByRole("navigation", { name: "PanelMenu" }); + const rows = within(nav).getAllByRole("button"); + expect(rows.map((row) => row.textContent)).toEqual([ + "Switch Account...", + "Usage Dashboard", + "Status Page", + "RefreshCtrl+R", + "Settings...Ctrl+,", + "About CodexBar (v0.70.0)", + "QuitCtrl+Q", + ]); + expect( + Array.from(nav.children).map((child) => + child.classList.contains("menu-surface__footer-sep") ? "sep" : child.textContent, + ), + ).toEqual([ + "sep", + "Switch Account...", + "Usage Dashboard", + "Status Page", + "sep", + "RefreshCtrl+R", + "Settings...Ctrl+,", + "About CodexBar (v0.70.0)", + "QuitCtrl+Q", + ]); + }); + + it("draws an SVG icon on every row except Refresh, which gets the taller row", async () => { + const noop = vi.fn(); + renderFooter([ + [ + { id: "switchAccount", label: "Switch Account...", onClick: noop }, + { id: "dashboard", label: "Usage Dashboard", onClick: noop }, + { id: "statusPage", label: "Status Page", onClick: noop }, + { id: "refresh", label: "Refresh", shortcut: "Ctrl+R", onClick: noop }, + { id: "settings", label: "Settings...", shortcut: "Ctrl+,", onClick: noop }, + { id: "about", label: "About CodexBar (v0.70.0)", onClick: noop }, + { id: "quit", label: "Quit", shortcut: "Ctrl+Q", onClick: noop }, + ], + ]); + + const nav = await screen.findByRole("navigation", { name: "PanelMenu" }); + const rows = within(nav).getAllByRole("button"); + expect(rows.map((row) => row.querySelectorAll("svg").length)).toEqual([1, 1, 1, 0, 1, 1, 1]); + expect( + rows.map((row) => row.classList.contains("menu-surface__footer-row--refresh")), + ).toEqual([false, false, false, true, false, false, false]); + expect( + Array.from(nav.querySelectorAll(".menu-surface__footer-shortcut")).map((s) => s.textContent), + ).toEqual(["Ctrl+R", "Ctrl+,", "Ctrl+Q"]); + }); + + it("skips empty groups, so no separator is drawn for them", async () => { + renderFooter([[], [{ id: "quit", label: "Quit", shortcut: "Ctrl+Q", onClick: vi.fn() }]]); + + const nav = await screen.findByRole("navigation", { name: "PanelMenu" }); + expect(nav.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(1); + expect(within(nav).getAllByRole("button").map((row) => row.textContent)).toEqual([ + "QuitCtrl+Q", + ]); + }); + + it("runs the clicked row's action and names its shortcut for assistive tech", async () => { + const onRefresh = vi.fn(); + const onSettings = vi.fn(); + renderFooter([ + [ + { id: "refresh", label: "Refresh", shortcut: "Ctrl+R", onClick: onRefresh }, + { id: "settings", label: "Settings...", shortcut: "Ctrl+,", onClick: onSettings }, + ], + ]); + + const settings = await screen.findByRole("button", { name: "Settings..." }); + expect(screen.getByRole("button", { name: "Refresh" })).toHaveAttribute( + "aria-keyshortcuts", + "Control+R", + ); + expect(settings).toHaveAttribute("aria-keyshortcuts", "Control+,"); + fireEvent.click(settings); + + expect(onSettings).toHaveBeenCalledTimes(1); + expect(onRefresh).toHaveBeenCalledTimes(0); + }); + + it("renders no footer when every group is empty", async () => { + renderFooter([[], []]); + + expect(await screen.findByText("cards")).toBeInTheDocument(); + expect(screen.queryByRole("navigation")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/MenuSurface.tsx b/apps/desktop-tauri/src/components/MenuSurface.tsx index f9b08c48b0..256bb67ba8 100644 --- a/apps/desktop-tauri/src/components/MenuSurface.tsx +++ b/apps/desktop-tauri/src/components/MenuSurface.tsx @@ -1,21 +1,70 @@ import type { CSSProperties, ReactNode } from "react"; import { useLocale } from "../hooks/useLocale"; +export type MenuRowId = + | "switchAccount" + | "dashboard" + | "statusPage" + | "refresh" + | "settings" + | "about" + | "quit"; + export interface MenuFooterRow { - icon: string; + id: MenuRowId; label: string; shortcut?: string; onClick: () => void; } +function lineIcon(...paths: ReactNode[]) { + return ( + + {paths} + + ); +} + +const ROW_ICONS: Record = { + switchAccount: lineIcon( + , + , + ), + dashboard: lineIcon( + , + , + ), + statusPage: lineIcon(), + refresh: null, + settings: lineIcon( + , + , + ), + about: lineIcon( + , + , + , + ), + quit: lineIcon( + , + , + ), +}; + interface MenuSurfaceProps { summary?: ReactNode; banner?: ReactNode; - /** Non-button content rendered in the footer nav BEFORE the mapped - * `footerRows` (e.g. the tray zoom control). Rendering it here — rather - * than as a `footerRows` entry — keeps it a plain `div`, not a `button`. */ - footerLead?: ReactNode; - footerRows?: MenuFooterRow[]; + footerGroups?: MenuFooterRow[][]; /** Inline style applied to the root `menu-surface` element (e.g. CSS * `zoom` for the tray flyout). */ style?: CSSProperties; @@ -25,47 +74,50 @@ interface MenuSurfaceProps { /** * Flush, compact container for the tray panel (`TrayPanel`), the only * dashboard layout. It renders in the tray-panel flyout window. - * - * Mirrors the upstream macOS `MenuContent`: a narrow VStack(spacing: 8) - * inside an NSMenu-like popover (310pt wide, vertical 6 / horizontal 10 - * padding, no hero framing). The body holds a stack of full provider - * cards (`MenuCard`) — one per enabled provider — exactly like upstream. */ export default function MenuSurface({ summary, banner, - footerLead, - footerRows, + footerGroups = [], style, children, }: MenuSurfaceProps) { const { t } = useLocale(); + const groups = footerGroups.filter((group) => group.length > 0); return (
{banner} {summary}
{children}
- {(footerLead || (footerRows && footerRows.length > 0)) && ( + {groups.length > 0 && ( )}
diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx index 7960659006..c171bd9bf6 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from "react"; import { Field, Select, Toggle } from "../components/FormControls"; +import { useDraftNumber } from "../hooks/useDraftNumber"; import { useLocale } from "../hooks/useLocale"; import type { FloatBarOrientation, @@ -14,30 +14,6 @@ interface Props { set: (patch: SettingsUpdate) => void; } -function useDraftNumber(value: number) { - const [draft, setDraft] = useState(value); - const [prev, setPrev] = useState(value); - if (value !== prev) { - setPrev(value); - setDraft(value); - } - - const commit = useCallback( - (next: number, onCommit: (value: number) => void) => { - // Dedupe against the committed prop value, which is the persisted - // source of truth. The parent's save is fire-and-forget, so we can't - // observe success/failure here — comparing to `value` (rather than an - // optimistically-advanced marker) means a failed save leaves the prop - // unchanged and a re-commit of the same number still fires the retry. - if (next === value) return; - onCommit(next); - }, - [value], - ); - - return { draft, setDraft, commit }; -} - /** * Settings UI block for the floating capacity bar. Rendered as one row * in the Display tab — kept in this module so the Display tab only diff --git a/apps/desktop-tauri/src/hooks/useDraftNumber.ts b/apps/desktop-tauri/src/hooks/useDraftNumber.ts new file mode 100644 index 0000000000..bc8406ce75 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useDraftNumber.ts @@ -0,0 +1,20 @@ +import { useCallback, useState } from "react"; + +export function useDraftNumber(value: number) { + const [draft, setDraft] = useState(value); + const [prev, setPrev] = useState(value); + if (value !== prev) { + setPrev(value); + setDraft(value); + } + + const commit = useCallback( + (next: number, onCommit: (value: number) => void) => { + if (next === value) return; + onCommit(next); + }, + [value], + ); + + return { draft, setDraft, commit }; +} diff --git a/apps/desktop-tauri/src/hooks/useSettings.test.tsx b/apps/desktop-tauri/src/hooks/useSettings.test.tsx index aa79b350c1..dd9afb1664 100644 --- a/apps/desktop-tauri/src/hooks/useSettings.test.tsx +++ b/apps/desktop-tauri/src/hooks/useSettings.test.tsx @@ -222,6 +222,21 @@ describe("useSettings update", () => { expect(result.current.settings.windowScalePercent).toBe(150); }); + it("reports a failed Panel scale save and reverts to the saved value", async () => { + const onDisk = { trayScalePercent: 100 } as unknown as SettingsSnapshot; + tauriMocks.getSettingsSnapshot.mockResolvedValue(onDisk); + tauriMocks.updateSettings.mockRejectedValueOnce(new Error("disk busy")); + const { result } = renderHook(() => useSettings(onDisk)); + await act(async () => {}); + + await act(async () => { + await result.current.update({ trayScalePercent: 150 }); + }); + + expect(result.current.error).toBe("disk busy"); + expect(result.current.settings.trayScalePercent).toBe(100); + }); + it("keeps the resolved shortcut map until the response supplies it", async () => { tauriMocks.updateSettings.mockReturnValueOnce(new Promise(() => {})); const initial = { diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 9ae83df78a..1a69719eac 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -1,17 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import type { BootstrapState, ProviderUsageSnapshot } from "../types/bridge"; +import type { BootstrapState } from "../types/bridge"; import { beginFlyoutGesture, dismissTrayPanel, endFlyoutGesture, - flyoutStoredSize, + getAppInfo, + getProviderDetail, + getSystemAccentColor, + openProviderDashboard, + openProviderStatusPage, openSettingsWindow, quitApp as quitApplication, - reanchorTrayPanel, reorderProviders, - setFlyoutSize, - updateSettings, } from "../lib/tauri"; import { useProviders } from "./useProviders"; import { useSettings } from "./useSettings"; @@ -21,7 +22,9 @@ import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; import { useSurfaceTarget } from "./useSurfaceMode"; import { useTrayPanelLayout } from "./useTrayPanelLayout"; import type { MenuFooterRow } from "../components/MenuSurface"; +import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; import { orderProviderSnapshots } from "../lib/providerOrder"; +import { clampTrayScalePercent } from "../lib/trayScale"; import { hydrateProviderSlots, orderedEnabledProviderSlots, @@ -30,18 +33,10 @@ import { const TRAY_INITIAL_REFRESH_DELAY_MS = 250; const DENSE_OVERVIEW_THRESHOLD = 32; -// ── Tray flyout zoom (footer slider, above Refresh) ─────────────────── -// Applied via CSS `zoom` on the MenuSurface root (see TrayPanel render). -export const TRAY_SCALE_MIN = 100; -export const TRAY_SCALE_MAX = 200; -export const TRAY_SCALE_STEP = 5; -const TRAY_SCALE_COMMIT_DEBOUNCE_MS = 250; - -function clampTrayScalePercent(value: number): number { - return Math.min( - TRAY_SCALE_MAX, - Math.max(TRAY_SCALE_MIN, Number.isFinite(value) ? value : 100), - ); +interface ProviderLinks { + providerId: string; + dashboard: boolean; + statusPage: boolean; } /** @@ -67,46 +62,40 @@ export function useTrayPanelController(state: BootstrapState) { const { t } = useLocale(); const surfaceTarget = useSurfaceTarget("trayPanel"); - // Zoom slider: LOCAL draft state drives both the thumb and the live CSS - // zoom preview while dragging; persistence trails behind a ~250ms debounce - // (fire-and-forget updateSettings). The settings_changed echo — from our - // own commit round-trip or another window — only re-syncs the draft when - // no debounce is pending, so it can't fight the thumb mid-drag. - const settingsTrayScalePercent = clampTrayScalePercent( - settings.trayScalePercent, - ); - const [trayScaleDraft, setTrayScaleDraft] = useState( - settingsTrayScalePercent, - ); - const trayScaleCommitTimerRef = useRef(undefined); + const trayScale = clampTrayScalePercent(settings.trayScalePercent) / 100; + + const [appVersion, setAppVersion] = useState(null); useEffect(() => { - if (trayScaleCommitTimerRef.current === undefined) { - setTrayScaleDraft(settingsTrayScalePercent); - } - }, [settingsTrayScalePercent]); - useEffect( - () => () => { - if (trayScaleCommitTimerRef.current !== undefined) { - window.clearTimeout(trayScaleCommitTimerRef.current); - } - }, - [], - ); - const handleTrayScaleChange = useCallback((value: number) => { - const next = clampTrayScalePercent(value); - setTrayScaleDraft(next); - if (trayScaleCommitTimerRef.current !== undefined) { - window.clearTimeout(trayScaleCommitTimerRef.current); - } - trayScaleCommitTimerRef.current = window.setTimeout(() => { - trayScaleCommitTimerRef.current = undefined; - void updateSettings({ trayScalePercent: next }).catch(() => {}); - }, TRAY_SCALE_COMMIT_DEBOUNCE_MS); + let cancelled = false; + void getAppInfo().then( + (info) => { + if (!cancelled) setAppVersion(info.version); + }, + () => {}, + ); + return () => { + cancelled = true; + }; + }, []); + + const [accentColor, setAccentColor] = useState(null); + useEffect(() => { + let cancelled = false; + const readAccent = () => { + void getSystemAccentColor().then( + (color) => { + if (!cancelled) setAccentColor(color); + }, + () => {}, + ); + }; + readAccent(); + window.addEventListener("focus", readAccent); + return () => { + cancelled = true; + window.removeEventListener("focus", readAccent); + }; }, []); - const trayScale = trayScaleDraft / 100; - const trayScaleFillPercent = - ((trayScaleDraft - TRAY_SCALE_MIN) / (TRAY_SCALE_MAX - TRAY_SCALE_MIN)) * - 100; const sorted = useMemo( () => @@ -176,6 +165,92 @@ export function useTrayPanelController(state: BootstrapState) { return [match]; }, [denseTrayProviders, sorted, selectedProviderId, gridExpanded]); + const actionProvider = + selectedProviderId !== null + ? (providersById.get(selectedProviderId) ?? null) + : sorted.length === 1 + ? sorted[0] + : null; + const actionProviderId = actionProvider?.providerId ?? null; + const [providerLinks, setProviderLinks] = useState(null); + useEffect(() => { + if (actionProviderId === null) return; + let cancelled = false; + void getProviderDetail(actionProviderId) + .then( + (detail) => ({ + dashboard: Boolean(detail.dashboardUrl), + statusPage: Boolean(detail.statusPageUrl), + }), + () => ({ dashboard: false, statusPage: false }), + ) + .then((links) => { + if (!cancelled) setProviderLinks({ providerId: actionProviderId, ...links }); + }); + return () => { + cancelled = true; + }; + }, [actionProviderId]); + const links = + providerLinks !== null && providerLinks.providerId === actionProviderId + ? providerLinks + : null; + + const openSettings = useCallback(() => { + void openSettingsWindow("general").finally(() => { + void getCurrentWindow().close(); + }); + }, []); + const openAbout = useCallback(() => { + void openSettingsWindow("about").finally(() => { + void getCurrentWindow().close(); + }); + }, []); + const quitApp = useCallback(() => { + void quitApplication(); + }, []); + + const actionRows: MenuFooterRow[] = []; + if (actionProvider !== null && hasSuccessfulClaudeCliQuota(actionProvider)) { + actionRows.push({ + id: "switchAccount", + label: t("TrayMenuSwitchAccount"), + onClick: () => void openSettingsWindow("providers").catch(() => {}), + }); + } + if (links?.dashboard) { + const { providerId } = links; + actionRows.push({ + id: "dashboard", + label: t("TrayMenuUsageDashboard"), + onClick: () => void openProviderDashboard(providerId).catch(() => {}), + }); + } + if (links?.statusPage) { + const { providerId } = links; + actionRows.push({ + id: "statusPage", + label: t("TrayMenuStatusPage"), + onClick: () => void openProviderStatusPage(providerId).catch(() => {}), + }); + } + const footerGroups: MenuFooterRow[][] = [ + actionRows, + [ + { id: "refresh", label: t("ActionRefresh"), shortcut: "Ctrl+R", onClick: refresh }, + { id: "settings", label: t("MenuSettings"), shortcut: "Ctrl+,", onClick: openSettings }, + { + id: "about", + label: appVersion + ? t("MenuAboutVersion").replace("{}", appVersion) + : t("MenuAbout"), + onClick: openAbout, + }, + { id: "quit", label: t("MenuQuit"), shortcut: "Ctrl+Q", onClick: quitApp }, + ], + ]; + const actionRowIds = actionRows.map((row) => row.id).join(","); + const layoutKey = useMemo( () => [ @@ -188,7 +263,8 @@ export function useTrayPanelController(state: BootstrapState) { expectsDenseOverview ? "dense" : "normal", hasLoadedCache ? "cache-ready" : "cache-pending", visibleProviders.map((provider) => provider.providerId).join(","), - trayScaleDraft, + actionRowIds, + trayScale, ].join("|"), [ selectedProviderId, @@ -200,122 +276,19 @@ export function useTrayPanelController(state: BootstrapState) { expectsDenseOverview, hasLoadedCache, visibleProviders, - trayScaleDraft, + actionRowIds, + trayScale, ], ); - // Flyout sizing: auto-fit to content until the user manually drags the border, - // then remember + honor their size (position always re-anchors above the tray). - // `flyoutSize`: undefined = loading, null = auto-fit, [w,h] = user's fixed size. - const [flyoutSize, setFlyoutSizeState] = useState< - [number, number] | null | undefined - >(undefined); - const [autoFitKilled, setAutoFitKilled] = useState(false); - useEffect(() => { - let active = true; - void flyoutStoredSize() - .then((size) => { - if (active) setFlyoutSizeState(size); - }) - .catch(() => { - if (active) setFlyoutSizeState(null); - }); - return () => { - active = false; - }; - }, []); - - const saveSizeTimerRef = useRef(undefined); - const handleUserResize = useCallback((width: number, height: number) => { - // Stop auto-fit immediately so it can't fight the drag; commit the size - // (state + persistence) after the drag settles. - setAutoFitKilled(true); - if (saveSizeTimerRef.current !== undefined) { - window.clearTimeout(saveSizeTimerRef.current); - } - saveSizeTimerRef.current = window.setTimeout(() => { - // The drag reports physical px. Keep logical px so the size holds on a - // monitor with another DPI; the scale is read once the window settled, - // so a resize caused by such a move converts with the new scale. - void getCurrentWindow() - .scaleFactor() - .then((scale) => { - const logical: [number, number] = [ - Math.round(width / scale), - Math.round(height / scale), - ]; - setFlyoutSizeState(logical); - // Re-anchor at the new size like every other resize, so a panel - // widened from its tray-facing edge doesn't stay past the taskbar. - void Promise.resolve(reanchorTrayPanel()).catch(() => {}); - return setFlyoutSize(logical[0], logical[1]); - }) - .catch(() => {}); - }, 300); - }, []); - useEffect( - () => () => { - if (saveSizeTimerRef.current !== undefined) { - window.clearTimeout(saveSizeTimerRef.current); - } - }, - [], - ); - - // TrayPanel now renders exclusively inside its own dedicated "flyout" OS - // window (see App.tsx's isFlyoutWindow() routing) — it is no longer a - // state of the shared `main` window's surface-mode machine. The old - // `useSurfaceMode() === "trayPanel"` check would be permanently false - // here (outside proof mode that machine only tracks Hidden/Settings), - // which would silently gate off the fixed-size restore + reveal below - // (useTrayPanelLayout's `isOpen` gate) — a user-resized flyout would never - // reveal itself. Hardcoded true: being mounted IS "the flyout is open". - const isFlyoutOpen = true; - const fixedFlyoutSize = Array.isArray(flyoutSize) ? flyoutSize : null; - const useWideColumns = - selectedProviderId === null && - fixedFlyoutSize !== null && - fixedFlyoutSize[0] >= 640; - const wideColumns = useMemo(() => { - const columns: ProviderUsageSnapshot[][] = [[], []]; - visibleProviders.forEach((provider, index) => { - columns[index % 2].push(provider); - }); - return columns; - }, [visibleProviders]); const { layoutReady, requestLayout } = useTrayPanelLayout({ canMeasure: hasLoadedCache || sorted.length > 0, denseOverview: expectsDenseOverview, detailMode: selectedProviderId !== null, layoutKey, - autoFit: flyoutSize === null && !autoFitKilled, - fixedSize: fixedFlyoutSize, - isOpen: isFlyoutOpen, zoom: trayScale, - onUserResize: handleUserResize, }); - const openSettings = useCallback(() => { - void openSettingsWindow("general").finally(() => { - void getCurrentWindow().close(); - }); - }, []); - const openAbout = useCallback(() => { - void openSettingsWindow("about").finally(() => { - void getCurrentWindow().close(); - }); - }, []); - const quitApp = useCallback(() => { - void quitApplication(); - }, []); - - const footerRows: MenuFooterRow[] = [ - { icon: "↻", label: t("ActionRefresh"), shortcut: "Ctrl+R", onClick: refresh }, - { icon: "⚙", label: t("MenuSettings"), shortcut: "Ctrl+,", onClick: openSettings }, - { icon: "ⓘ", label: t("MenuAbout"), onClick: openAbout }, - { icon: "⌧", label: t("MenuQuit"), shortcut: "Ctrl+Q", onClick: quitApp }, - ]; - // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -376,7 +349,7 @@ export function useTrayPanelController(state: BootstrapState) { void endFlyoutGesture().catch(() => {}); }, []); - const revealClassName = `tray-panel-reveal${layoutReady ? " tray-panel-reveal--ready" : ""}${expectsDenseOverview ? " tray-panel-reveal--dense" : ""}${fixedFlyoutSize ? " tray-panel-reveal--usersized" : ""}`; + const revealClassName = `tray-panel-reveal${layoutReady ? " tray-panel-reveal--ready" : ""}`; return { t, @@ -385,21 +358,17 @@ export function useTrayPanelController(state: BootstrapState) { refreshingProviderIds, refresh, hasCachedData, - trayScaleDraft, trayScale, - trayScaleFillPercent, - handleTrayScaleChange, + accentColor, sorted, gridProviders, selectedProviderId, gridExpanded, setGridExpanded, visibleProviders, - wideColumns, - useWideColumns, layoutReady, requestLayout, - footerRows, + footerGroups, updateState, checkNow, download, diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index 1e70c015ff..f4578071b9 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -26,7 +26,7 @@ const tauriMocks = vi.hoisted(() => ({ const windowMocks = vi.hoisted(() => ({ setSize: vi.fn().mockResolvedValue(undefined), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 420 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 420 }), getCurrentWindow: vi.fn(), LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), @@ -41,19 +41,20 @@ let surface: HTMLElement; let feedbackObserverCallbacks = 0; class StyleFeedbackResizeObserver { - private mutationObserver: MutationObserver | null = null; + private mutationObservers: MutationObserver[] = []; constructor(private readonly callback: ResizeObserverCallback) {} observe(target: Element): void { - this.mutationObserver = new MutationObserver(() => { + const mutationObserver = new MutationObserver(() => { feedbackObserverCallbacks += 1; this.callback([], this as unknown as ResizeObserver); }); - this.mutationObserver.observe(target, { + mutationObserver.observe(target, { attributes: true, attributeFilter: ["style"], }); + this.mutationObservers.push(mutationObserver); } unobserve(): void { @@ -61,8 +62,10 @@ class StyleFeedbackResizeObserver { } disconnect(): void { - this.mutationObserver?.disconnect(); - this.mutationObserver = null; + for (const mutationObserver of this.mutationObservers) { + mutationObserver.disconnect(); + } + this.mutationObservers = []; } } @@ -78,8 +81,6 @@ function mountSurface(): void { surface = document.querySelector(".menu-surface--tray")!; } -/** Drive the auto-fit measure: jsdom rects are 0, so scrollHeight dominates → - * contentHeight = scrollHeight + 4 (measure pipeline, zoom=1). */ function setScrollHeight(px: number): void { Object.defineProperty(surface, "scrollHeight", { configurable: true, @@ -126,9 +127,6 @@ describe("useTrayPanelLayout sizing", () => { ); windowMocks.getCurrentWindow.mockReturnValue({ setSize: windowMocks.setSize, - close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(SCALE), - onResized: vi.fn().mockResolvedValue(() => {}), innerSize: windowMocks.innerSize, } as never); }); @@ -202,8 +200,37 @@ describe("useTrayPanelLayout sizing", () => { ); }); + it("leaves no measuring overrides behind when style feedback overlaps the first pass", async () => { + vi.stubGlobal("ResizeObserver", StyleFeedbackResizeObserver); + setScrollHeight(1_200); + const html = document.documentElement; + const body = surface.querySelector(".menu-surface__body")!; + const stack = surface.querySelector(".menu-stack")!; + + const { result } = renderHook(() => useTrayPanelLayout(hookProps())); + await waitFor(() => expect(result.current.layoutReady).toBe(true), { + timeout: 3000, + }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 1_000)); + }); + + expect(feedbackObserverCallbacks).toBeGreaterThan(0); + expect(lastResize()).toEqual({ width: 310, height: 884 }); + expect(surface.style.maxHeight).toBe("884px"); + expect(surface.style.minHeight).toBe(""); + expect(surface.style.height).toBe(""); + expect(surface.style.overflow).toBe(""); + expect(body.style.overflow).toBe(""); + expect(body.style.flex).toBe(""); + expect(stack.style.overflow).toBe(""); + expect(document.body.style.overflow).toBe(""); + expect(document.body.style.minHeight).toBe(""); + expect(html.style.overflow).toBe(""); + }); + it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => { - setScrollHeight(535); // → 539 logical → 674 physical + setScrollHeight(538); // → 539 logical → 674 physical const { result } = renderHook(() => useTrayPanelLayout(hookProps())); await waitFor(() => expect(result.current.layoutReady).toBe(true), { timeout: 3000, @@ -215,48 +242,46 @@ describe("useTrayPanelLayout sizing", () => { ).toBe(true); // (1) Stable one-way +5-physical change COMMITS (no blanket deadband). - await nudgePass(result, 539, "543px"); // → 543 → 679 phys - expect(lastResize()).toEqual({ width: 328, height: 543 }); + await nudgePass(result, 542, "543px"); // → 543 → 679 phys + expect(lastResize()).toEqual({ width: 310, height: 543 }); expect(surface.style.maxHeight).toBe("543px"); // (2) Reporter-class alternation: one commit to 549 (686 phys), then the // 543↔549 pair (679↔686 phys, span 7) is detected on the flip-down. - await nudgePass(result, 545, "549px"); // → 549 → 686 phys - expect(lastResize()).toEqual({ width: 328, height: 549 }); + await nudgePass(result, 548, "549px"); // → 549 → 686 phys + expect(lastResize()).toEqual({ width: 310, height: 549 }); expect(surface.style.maxHeight).toBe("549px"); const lockedResizes = windowMocks.setSize.mock.calls.length; const lockedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length; - // Flip-down evidence (measure 539→543): detected, suppressed, and the DOM - // constraint stays the RETAINED height — never the smaller candidate. - await nudgePass(result, 539, "549px"); + await nudgePass(result, 542, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); expect(surface.style.maxHeight).toBe("549px"); // (3) Repeated flips stay suppressed; surface stays at the retained 549. - await nudgePass(result, 545, "549px"); - await nudgePass(result, 539, "549px"); + await nudgePass(result, 548, "549px"); + await nudgePass(result, 542, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); expect(surface.style.maxHeight).toBe("549px"); // Retained window (549 logical) fully contains BOTH measured sides // (543 and 549 candidates) — no clipping by construction. - expect(lastResize()).toEqual({ width: 328, height: 549 }); + expect(lastResize()).toEqual({ width: 310, height: 549 }); // (4) Real growth outside the pair clears the lock and commits once. - await nudgePass(result, 700, "704px"); // → 704 → 880 phys - expect(lastResize()).toEqual({ width: 328, height: 704 }); + await nudgePass(result, 703, "704px"); // → 704 → 880 phys + expect(lastResize()).toEqual({ width: 310, height: 704 }); expect(surface.style.maxHeight).toBe("704px"); // Real shrink commits once. - await nudgePass(result, 410, "420px"); // → clamp 420 → 525 phys - expect(lastResize()).toEqual({ width: 328, height: 420 }); + await nudgePass(result, 413, "420px"); // → clamp 420 → 525 phys + expect(lastResize()).toEqual({ width: 310, height: 420 }); expect(surface.style.maxHeight).toBe("420px"); // (5) No blanket absorption: a stable 1-physical-px change still commits. - await nudgePass(result, 417, "421px"); // → 421 → 526 phys - expect(lastResize()).toEqual({ width: 328, height: 421 }); + await nudgePass(result, 420, "421px"); // → 421 → 526 phys + expect(lastResize()).toEqual({ width: 310, height: 421 }); expect(surface.style.maxHeight).toBe("421px"); }, 30_000); // 8 bounded 3s settling passes + 3s readiness can exceed Vitest's 5s default. @@ -272,7 +297,7 @@ describe("useTrayPanelLayout sizing", () => { }, ); - setScrollHeight(535); // → 539 target (674 phys requested) → applied 669 + setScrollHeight(538); // → 539 target (674 phys requested) → applied 669 const { result } = renderHook(() => useTrayPanelLayout(hookProps())); await waitFor(() => expect(result.current.layoutReady).toBe(true), { timeout: 3000, @@ -289,20 +314,43 @@ describe("useTrayPanelLayout sizing", () => { // target says 674: suppress (no setSize/reanchor), adopt the candidate. // The prior frame is 420 (525 phys), 144 px away from 669 — the A↔B // detector cannot fire here. - await nudgePass(result, 531, "535px"); // → 535 → 669 phys + await nudgePass(result, 534, "535px"); // → 535 → 669 phys expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); expect(surface.style.maxHeight).toBe("535px"); // Identical next pass: now exact same-frame stable — still zero churn. - await nudgePass(result, 531, "535px"); + await nudgePass(result, 534, "535px"); expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); expect(surface.style.maxHeight).toBe("535px"); // Recovery: a real +5-physical change from the reconciled frame commits. - await nudgePass(result, 535, "539px"); // → 539 → 674 phys - expect(lastResize()).toEqual({ width: 328, height: 539 }); + await nudgePass(result, 538, "539px"); // → 539 → 674 phys + expect(lastResize()).toEqual({ width: 310, height: 539 }); expect(surface.style.maxHeight).toBe("539px"); }); + + it("sizes the window at 310 px times the Panel scale and leaves the spare height above the footer out", async () => { + const body = surface.querySelector(".menu-surface__body")!; + const footer = surface.querySelector(".menu-surface__footer")!; + surface.getBoundingClientRect = () => + ({ top: 0, bottom: 800, height: 800 }) as DOMRect; + body.getBoundingClientRect = () => + ({ top: 0, bottom: 400, height: 400 }) as DOMRect; + footer.getBoundingClientRect = () => + ({ top: 601, bottom: 799, height: 198 }) as DOMRect; + setScrollHeight(532); + + const { result } = renderHook(() => + useTrayPanelLayout(hookProps({ detailMode: false, zoom: 1.5 })), + ); + await waitFor(() => expect(result.current.layoutReady).toBe(true), { + timeout: 3000, + }); + + expect(windowMocks.setSize.mock.calls[0][0]).toEqual({ width: 465, height: 200 }); + expect(lastResize()).toEqual({ width: 465, height: 600 }); + expect(surface.style.maxHeight).toBe("400px"); + }); }); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index e2543d8921..7267f83357 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -1,9 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { - getCurrentWindow, - LogicalSize, - PhysicalSize, -} from "@tauri-apps/api/window"; +import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import { getWorkAreaRect, reanchorTrayPanel, @@ -16,43 +12,19 @@ import { type TrayAutoFitState, } from "../lib/traySizing"; -const TRAY_WIDTH = 328; +const TRAY_BASE_WIDTH = 310; const TRAY_MAX_MEASURE_HEIGHT = 920; const TRAY_OVERVIEW_MIN_HEIGHT = 200; const TRAY_DETAIL_MIN_HEIGHT = 420; const TRAY_DENSE_OVERVIEW_HEIGHT = 776; - -/** Scale a measured content height into the window's pixel space. TrayPanel - * renders the surface with `zoom: trayScale` (CSS zoom), but every DOM metric - * the layout pass reads — scrollHeight AND bounding rects — is reported in - * the surface's LOCAL, pre-zoom px (measured identical at 100/150/200%). The - * WIN32 window must be sized in POST-zoom px or tall cards clip below the - * fold (and the zoomed width overflows into a horizontal scrollbar), so the - * raw measure is multiplied by the active zoom before clamping (#265). */ -export function scaledContentHeight(rawHeight: number, zoom: number): number { - if (!Number.isFinite(zoom) || zoom <= 0 || zoom === 1) return rawHeight; - return Math.round(rawHeight * zoom); -} +const TRAY_HEIGHT_SLACK = 1; export interface TrayPanelLayoutOptions { canMeasure: boolean; denseOverview: boolean; detailMode: boolean; layoutKey: string; - /** Auto-fit the window to its content (until the user sets a size). */ - autoFit?: boolean; - /** The user's remembered size in logical px, re-applied + re-anchored each - * time the flyout opens. `null` when the user has not resized yet. */ - fixedSize?: [number, number] | null; - /** Whether the flyout is currently open (surface mode === trayPanel). Used as - * the "just opened" trigger for the fixed-size restore + re-anchor. */ - isOpen?: boolean; - /** Active tray-panel zoom factor (the CSS `zoom` TrayPanel applies to the - * surface). Auto-fit scales its measured content height by this before - * clamping — see `scaledContentHeight`. Defaults to 1 (no zoom). */ zoom?: number; - /** Called with the new logical size on a genuine user drag-resize. */ - onUserResize?: (width: number, height: number) => void; } export interface TrayPanelLayout { @@ -65,132 +37,25 @@ export function useTrayPanelLayout({ denseOverview, detailMode, layoutKey, - autoFit = true, - fixedSize = null, - isOpen = false, zoom = 1, - onUserResize, }: TrayPanelLayoutOptions): TrayPanelLayout { const [layoutReady, setLayoutReady] = useState(false); const [layoutRevision, setLayoutRevision] = useState(0); const layoutReadyRef = useRef(false); const resizeRunRef = useRef(0); + const activeResizeRef = useRef>(Promise.resolve()); const layoutTimerRef = useRef(undefined); - // The window's actual PHYSICAL size after the last resize WE performed. The - // onResized event also reports physical pixels, so comparing physical-to- - // physical needs no scale factor — Tauri scaleFactor / webview devicePixelRatio - // / Win32 can all disagree on a scaled display, and that disagreement is what - // compounded a per-open size growth. const lastSizeRef = useRef<{ width: number; height: number } | null>(null); const programmaticInFlightRef = useRef(0); - // Auto-fit sizing decision state (committed frame + one-frame history + - // learned oscillation pair) — all frame logic lives in lib/traySizing so - // the #261 cycle detection stays pure and directly testable. const sizingStateRef = useRef(EMPTY_AUTOFIT_STATE); - const fixedSizeRef = useRef(fixedSize); - useEffect(() => { - fixedSizeRef.current = fixedSize; - }, [fixedSize]); - const onUserResizeRef = useRef(onUserResize); - useEffect(() => { - onUserResizeRef.current = onUserResize; - }, [onUserResize]); - - // Resize the window and record the resulting ACTUAL physical size. Wrapped in - // an in-flight counter so the Resized event(s) this triggers can never be - // mistaken for a user drag, regardless of event timing. - const applySize = useCallback( - async (size: LogicalSize | PhysicalSize): Promise => { - const win = getCurrentWindow(); - programmaticInFlightRef.current += 1; - try { - await win.setSize(size); - const actual = await win.innerSize(); - lastSizeRef.current = { width: actual.width, height: actual.height }; - } catch { - /* ignore */ - } finally { - programmaticInFlightRef.current -= 1; - } - }, - [], - ); - // Report genuine user drag-resizes. Ignore resizes that fire while WE are - // resizing (in-flight counter) or whose physical size still matches the last - // size we applied; anything else is the user dragging the border. Everything - // is in PHYSICAL pixels — no scale conversion, so it can't drift. A DPI change - // (the panel moved onto a monitor with other scaling) announces its rescaled - // size first; adopting it keeps that rescale from counting as a user resize, - // which would freeze an auto-fit panel at a fixed size. - useEffect(() => { - let unlisten: (() => void) | undefined; - let unlistenScale: (() => void) | undefined; - let cancelled = false; + const applySize = useCallback(async (size: LogicalSize): Promise => { const win = getCurrentWindow(); - void (async () => { - try { - unlistenScale = await win.onScaleChanged(({ payload }) => { - lastSizeRef.current = { - width: payload.size.width, - height: payload.size.height, - }; - }); - } catch { - unlistenScale = undefined; - } - try { - unlisten = await win.onResized(({ payload }) => { - if (programmaticInFlightRef.current > 0) return; - const last = lastSizeRef.current; - if ( - last && - Math.abs(payload.width - last.width) <= 3 && - Math.abs(payload.height - last.height) <= 3 - ) { - return; - } - onUserResizeRef.current?.(payload.width, payload.height); - }); - } catch { - unlisten = undefined; - } - if (cancelled) { - unlisten?.(); - unlistenScale?.(); - } - })(); - return () => { - cancelled = true; - unlisten?.(); - unlistenScale?.(); - }; + await win.setSize(size); + const actual = await win.innerSize(); + lastSizeRef.current = { width: actual.width, height: actual.height }; }, []); - // User-sized flyout: on each open, apply the remembered size, re-anchor above - // the tray at THAT size (so the anchor math uses the real height, not the - // default), then reveal. Content scrolls inside the fixed window via CSS. - const hasFixedSize = fixedSize != null; - useEffect(() => { - if (autoFit || !isOpen || !canMeasure) return; - const fixed = fixedSizeRef.current; - if (!fixed) return; - let cancelled = false; - void (async () => { - // `fixed` is the user's remembered LOGICAL size; the window converts it - // with the DPI of the monitor it is on now. - await applySize(new LogicalSize(fixed[0], fixed[1])); - await Promise.resolve(reanchorTrayPanel()).catch(() => {}); - if (cancelled) return; - layoutReadyRef.current = true; - setLayoutReady(true); - await Promise.resolve(revealTrayPanelWindow()).catch(() => {}); - })(); - return () => { - cancelled = true; - }; - }, [autoFit, isOpen, canMeasure, hasFixedSize, applySize]); - const requestLayout = useCallback(() => { if (layoutTimerRef.current !== undefined) { window.clearTimeout(layoutTimerRef.current); @@ -208,19 +73,18 @@ export function useTrayPanelLayout({ const surface = document.querySelector(".menu-surface--tray"); if (!surface || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { - // Measuring temporarily removes the surface/body constraints, which - // resizes the observed surface. Do not feed that programmatic change - // back into another pass or the capped flyout flashes between its - // measured and committed layouts forever. - if ( - layoutReadyRef.current && - programmaticInFlightRef.current > 0 - ) { - return; - } + // Measuring lifts the surface/body constraints, which resizes the + // observed parts. Feeding that back would restart the pass forever. + if (programmaticInFlightRef.current > 0) return; requestLayout(); }); - observer.observe(surface); + for (const part of [ + surface, + surface.querySelector(".menu-surface__body"), + surface.querySelector(".menu-surface__footer"), + ]) { + if (part) observer.observe(part); + } return () => observer.disconnect(); }, [requestLayout]); @@ -233,16 +97,34 @@ export function useTrayPanelLayout({ }, []); useEffect(() => { - if (!autoFit || !canMeasure) return; + if (!canMeasure) return; const minHeight = detailMode ? TRAY_DETAIL_MIN_HEIGHT : denseOverview ? TRAY_DENSE_OVERVIEW_HEIGHT : TRAY_OVERVIEW_MIN_HEIGHT; + const width = Math.round(TRAY_BASE_WIDTH * zoom); const resize = async () => { const run = ++resizeRunRef.current; + // A superseded pass can still be suspended with its measuring + // overrides applied; wait for it so `previous` below is never those. + const prior = activeResizeRef.current; + let finished!: () => void; + activeResizeRef.current = new Promise((resolve) => { + finished = resolve; + }); + try { + await prior; + if (run !== resizeRunRef.current) return; + await measure(run); + } finally { + finished(); + } + }; + + const measure = async (run: number) => { const surface = document.querySelector(".menu-surface--tray"); if (!surface) return; const html = document.documentElement; @@ -258,6 +140,7 @@ export function useTrayPanelLayout({ const body = surface.querySelector(".menu-surface__body"); const stack = surface.querySelector(".menu-stack"); + const footer = surface.querySelector(".menu-surface__footer"); const previous = { htmlOverflow: html.style.overflow, @@ -298,19 +181,19 @@ export function useTrayPanelLayout({ } }; - // Suppress every Resized event this whole auto-fit pass causes (the burst - // of setSize calls + any that arrive shortly after) so none is mistaken - // for a user drag. The trailing delay absorbs late-delivered events. programmaticInFlightRef.current += 1; try { - if (!layoutReadyRef.current) { + const committed = sizingStateRef.current.committed; + if (!layoutReadyRef.current || committed?.width !== width) { + const height = + layoutReadyRef.current && committed ? committed.height : minHeight; sizingStateRef.current = recordAutoFitCommit( sizingStateRef.current, - TRAY_WIDTH, - minHeight, + width, + height, window.devicePixelRatio, ); - await applySize(new LogicalSize(TRAY_WIDTH, minHeight)); + await applySize(new LogicalSize(width, height)); } await new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -318,37 +201,24 @@ export function useTrayPanelLayout({ if (run !== resizeRunRef.current) return; + // WebView2 reports bounding rects in rendered px but scrollHeight in + // the zoomed surface's local px (measured on Edge 154), so the local + // value is scaled before the two are compared. const surfaceRect = surface.getBoundingClientRect(); - // Everything measured here is in the surface's LOCAL, pre-zoom px; - // scale into post-zoom window px via `scaledContentHeight` at each - // derivation point so the clamped window fits the rendered content. - let contentHeight = scaledContentHeight( - Math.max(surface.scrollHeight, Math.ceil(surfaceRect.height)), - zoom, - ); - let maxBottom = surfaceRect.top + contentHeight; const bodyRect = body?.getBoundingClientRect(); - if (bodyRect && bodyRect.height > 0) { - const scaledBottom = - surfaceRect.top + - scaledContentHeight(bodyRect.bottom - surfaceRect.top, zoom); - if (scaledBottom > maxBottom) { - maxBottom = scaledBottom; - } - } - const footer = surface.querySelector(".menu-surface__footer"); const footerRect = footer?.getBoundingClientRect(); - if (footerRect && footerRect.height > 0) { - const scaledBottom = - surfaceRect.top + - scaledContentHeight(footerRect.bottom - surfaceRect.top, zoom); - if (scaledBottom > maxBottom) { - maxBottom = scaledBottom; - } - } - contentHeight = Math.ceil(maxBottom - surfaceRect.top) + 4; - - const height = Math.min(Math.max(contentHeight, minHeight), maxHeight); + const spare = + bodyRect && footerRect + ? Math.max(0, footerRect.top - bodyRect.bottom) + : 0; + const contentBottom = Math.max( + surfaceRect.top + surface.scrollHeight * zoom, + surfaceRect.bottom, + ); + const height = Math.min( + Math.max(Math.ceil(contentBottom - spare) + TRAY_HEIGHT_SLACK, minHeight), + maxHeight, + ); // #261: two-state cycle detection on physical targets. Normal rule // commits ANY real change (even +5 physical px); only exact same- @@ -359,11 +229,9 @@ export function useTrayPanelLayout({ const decision = decideTrayHeight( { measuredHeight: height, - expectedWidth: TRAY_WIDTH, + expectedWidth: width, minHeight, maxHeight, - // WebView layout px ↔ Win32 physical px ratio; CSS zoom does not - // affect it (zoom is already in `height` via scaledContentHeight). scaleFactor: window.devicePixelRatio, zoom, lastAppliedPhysicalHeight: lastSizeRef.current?.height ?? null, @@ -371,11 +239,11 @@ export function useTrayPanelLayout({ sizingStateRef.current, ); sizingStateRef.current = decision.state; - surface.style.maxHeight = `${decision.height}px`; + surface.style.maxHeight = `${decision.height / zoom}px`; committedHeight = true; if (decision.commit) { - await applySize(new LogicalSize(TRAY_WIDTH, decision.height)); + await applySize(new LogicalSize(width, decision.height)); await Promise.resolve(reanchorTrayPanel()).catch(() => {}); } @@ -418,7 +286,7 @@ export function useTrayPanelLayout({ window.clearTimeout(timer); resizeRunRef.current += 1; }; - }, [autoFit, canMeasure, denseOverview, detailMode, layoutRevision, applySize, zoom]); + }, [canMeasure, denseOverview, detailMode, layoutRevision, applySize, zoom]); return { layoutReady, requestLayout }; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index f64b59e2c3..647f1bb135 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -225,7 +225,11 @@ export const ALL_LOCALE_KEYS = [ "ButtonOpenProviderSettings", "MenuSettings", "MenuAbout", + "MenuAboutVersion", "MenuQuit", + "TrayMenuSwitchAccount", + "TrayMenuUsageDashboard", + "TrayMenuStatusPage", "StatusJustUpdated", "StatusUnableToGetUsage", "ActionRefresh", @@ -694,7 +698,8 @@ export const ALL_LOCALE_KEYS = [ "DisplayModeMinimal", "TrayPanelAlwaysOnTopLabel", "TrayPanelAlwaysOnTopHelper", - "TrayMoveHandleHint", + "PanelScaleLabel", + "PanelScaleHelper", "WindowMinimize", "WindowRestore", "WindowClose", @@ -784,7 +789,6 @@ export const ALL_LOCALE_KEYS = [ "PanelAllProvidersShort", "PanelShowAllProviders", "PanelShowFewerProviders", - "PanelZoom", "PanelMenu", "PanelCopied", "PanelToday", diff --git a/apps/desktop-tauri/src/lib/menuSelection.test.ts b/apps/desktop-tauri/src/lib/menuSelection.test.ts new file mode 100644 index 0000000000..34bd3c309d --- /dev/null +++ b/apps/desktop-tauri/src/lib/menuSelection.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { accentSelectionStyle } from "./menuSelection"; + +describe("accentSelectionStyle", () => { + it("paints the selection with the Windows accent and white text when white reads on it", () => { + expect(accentSelectionStyle("#0078d4")).toEqual({ + "--mac-selection-bg": "#0078d4", + "--mac-selection-text": "#fff", + }); + }); + + it("switches to black text on a light accent", () => { + expect(accentSelectionStyle("#ffb900")).toEqual({ + "--mac-selection-bg": "#ffb900", + "--mac-selection-text": "#000", + }); + }); + + it("uses solid black on a mid gray accent, where 85% black would fall below 4.5:1", () => { + expect(accentSelectionStyle("#7a7a7a")).toEqual({ + "--mac-selection-bg": "#7a7a7a", + "--mac-selection-text": "#000", + }); + }); + + it("keeps the stylesheet defaults without an accent", () => { + expect(accentSelectionStyle(null)).toEqual({}); + expect(accentSelectionStyle("accent")).toEqual({}); + }); +}); diff --git a/apps/desktop-tauri/src/lib/menuSelection.ts b/apps/desktop-tauri/src/lib/menuSelection.ts new file mode 100644 index 0000000000..4e8d4586ca --- /dev/null +++ b/apps/desktop-tauri/src/lib/menuSelection.ts @@ -0,0 +1,20 @@ +import type { CSSProperties } from "react"; + +const HEX_COLOR = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; + +function srgbByteToLinear(byte: number): number { + const c = byte / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +export function accentSelectionStyle(accent: string | null): CSSProperties { + const match = accent ? HEX_COLOR.exec(accent) : null; + if (!match) return {}; + const [r, g, b] = match.slice(1).map((hex) => srgbByteToLinear(parseInt(hex, 16))); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const whiteContrast = 1.05 / (luminance + 0.05); + return { + "--mac-selection-bg": accent, + "--mac-selection-text": whiteContrast >= 4.5 ? "#fff" : "#000", + } as CSSProperties; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index d1f8b7131a..67e5f1bf88 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -550,19 +550,8 @@ export function revealTrayPanelWindow(): Promise { return invoke("reveal_tray_panel_window"); } -/** Persist the user's manually-chosen flyout (Pop Out Dashboard) size. */ -export function setFlyoutSize(width: number, height: number): Promise { - return invoke("set_flyout_size", { width, height }); -} - -/** Forget where the user dragged the flyout and anchor it to the tray again. */ -export function resetFlyoutPosition(): Promise { - return invoke("reset_flyout_position"); -} - -/** The remembered flyout size ([w, h]) if the user has resized it, else null. */ -export function flyoutStoredSize(): Promise<[number, number] | null> { - return invoke<[number, number] | null>("flyout_stored_size"); +export function getSystemAccentColor(): Promise { + return invoke("get_system_accent_color"); } export function quitApp(): Promise { diff --git a/apps/desktop-tauri/src/lib/trayScale.test.ts b/apps/desktop-tauri/src/lib/trayScale.test.ts new file mode 100644 index 0000000000..ce10786fe8 --- /dev/null +++ b/apps/desktop-tauri/src/lib/trayScale.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { clampTrayScalePercent } from "./trayScale"; + +describe("clampTrayScalePercent", () => { + it("keeps the panel scale between 100% and 200%", () => { + expect(clampTrayScalePercent(150)).toBe(150); + expect(clampTrayScalePercent(40)).toBe(100); + expect(clampTrayScalePercent(260)).toBe(200); + expect(clampTrayScalePercent(Number.NaN)).toBe(100); + }); +}); diff --git a/apps/desktop-tauri/src/lib/trayScale.ts b/apps/desktop-tauri/src/lib/trayScale.ts new file mode 100644 index 0000000000..ef8878157e --- /dev/null +++ b/apps/desktop-tauri/src/lib/trayScale.ts @@ -0,0 +1,8 @@ +export const TRAY_SCALE_MIN = 100; +export const TRAY_SCALE_MAX = 200; +export const TRAY_SCALE_STEP = 5; + +export function clampTrayScalePercent(value: number): number { + if (!Number.isFinite(value)) return TRAY_SCALE_MIN; + return Math.min(TRAY_SCALE_MAX, Math.max(TRAY_SCALE_MIN, value)); +} diff --git a/apps/desktop-tauri/src/lib/traySizing.test.ts b/apps/desktop-tauri/src/lib/traySizing.test.ts index ff990acef3..2dce585a9c 100644 --- a/apps/desktop-tauri/src/lib/traySizing.test.ts +++ b/apps/desktop-tauri/src/lib/traySizing.test.ts @@ -8,7 +8,7 @@ import { type TraySizingInput, } from "./traySizing"; -const TRAY_WIDTH = 328; +const TRAY_WIDTH = 310; const MIN = 420; const MAX = 920; diff --git a/apps/desktop-tauri/src/lib/traySizing.ts b/apps/desktop-tauri/src/lib/traySizing.ts index 10a357535e..136800e523 100644 --- a/apps/desktop-tauri/src/lib/traySizing.ts +++ b/apps/desktop-tauri/src/lib/traySizing.ts @@ -74,7 +74,6 @@ export const EMPTY_AUTOFIT_STATE: TrayAutoFitState = { export interface TraySizingInput { /** Measured content height, logical px, post zoom-scale, pre clamp. */ measuredHeight: number; - /** Logical content width the flyout always carries (TRAY_WIDTH). */ expectedWidth: number; minHeight: number; maxHeight: number; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index b2c32b6156..6f78479c4e 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1047,180 +1047,29 @@ html { user never sees the window jump around. Once layout is measured and the window is anchored in its final position, .tray-panel-reveal--ready fades the content in smoothly. */ +:root { + --mac-panel-bg: #dedee2; +} html:has(.tray-panel-reveal), -body:has(.tray-panel-reveal) { - background: var(--menu-popover-bg); +body:has(.tray-panel-reveal), +.tray-panel-reveal { + background: var(--mac-panel-bg); } .tray-panel-reveal { display: flex; flex-direction: column; min-height: 100vh; - background: var(--menu-popover-bg); opacity: 0; } .tray-panel-reveal--ready { opacity: 1; transition: opacity 80ms ease-out; } - -.tray-panel-reveal--dense .menu-surface--tray { - min-height: 100vh; -} - -/* User-resized flyout: the window is a fixed (remembered) size, so the surface - fills the viewport height and its body scrolls instead of the window growing - to fit content. */ -.tray-panel-reveal--usersized { - height: 100vh; - min-height: 0; -} -.tray-panel-reveal--usersized .menu-surface--tray { - flex: 1 1 auto; - min-height: 0; - height: auto; - max-height: 100%; -} - -/* User-sized flyout body must scroll vertically when content overflows the - remembered window height — the base `.menu-surface--tray .menu-surface__body` - rule (below) uses `overflow-y: visible` because auto-fit sizes the window to - content instead of scrolling. Usersized mode is a fixed window, so it needs - the opposite: clip + scroll. The body also flex-grows to absorb any free - space so the footer (Zoom/Refresh/Settings/About/Quit) pins to the bottom - edge instead of floating mid-panel after the user widens the flyout. - Scoped to `--usersized` only; auto-fit path is untouched. Applies in both - narrow (single column) and wide (side-by-side, see the 640px media query - below) layouts. */ -.tray-panel-reveal--usersized .menu-surface--tray .menu-surface__body { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; -} - -/* Side-by-side provider cards once the user has widened the flyout enough to - fit two columns. The columns are independent so a tall card in one column - doesn't force empty space under the shorter card beside it. */ -@media (min-width: 640px) { - .tray-panel-reveal--usersized .menu-surface--tray .menu-stack { - display: flex; - flex-direction: row; - align-content: flex-start; - align-items: flex-start; - gap: 12px 16px; - width: 100%; - } - - .tray-panel-reveal--usersized .menu-surface--tray .menu-stack__column { - flex: 1 1 0; - min-width: min(300px, 100%); - display: flex; - flex-direction: column; - gap: 12px; - } - - .tray-panel-reveal--usersized .menu-surface--tray .menu-stack__sep { - display: none; - } -} - -/* Flyout window handles. Resize grips are invisible strips on every edge and - corner (a moved flyout can face open screen on any side); the cursor change - signals they're draggable. The move strip spans the top between the corner - grips, under the top edge grip, and shows a small grab bar. */ -.tray-resize { - position: fixed; - z-index: 50; -} -.tray-resize--top, -.tray-resize--bottom { - left: 0; - right: 0; - height: 6px; - cursor: ns-resize; -} -.tray-resize--top { - top: 0; -} -.tray-resize--bottom { - bottom: 0; -} -.tray-resize--left, -.tray-resize--right { - top: 0; - bottom: 0; - width: 6px; - cursor: ew-resize; -} -.tray-resize--left { - left: 0; -} -.tray-resize--right { - right: 0; -} -.tray-resize--topleft, -.tray-resize--topright, -.tray-resize--bottomleft, -.tray-resize--bottomright { - width: 14px; - height: 14px; - z-index: 51; -} -.tray-resize--topleft { - top: 0; - left: 0; - cursor: nwse-resize; -} -.tray-resize--bottomright { - bottom: 0; - right: 0; - cursor: nwse-resize; -} -.tray-resize--topright { - top: 0; - right: 0; - cursor: nesw-resize; -} -.tray-resize--bottomleft { - bottom: 0; - left: 0; - cursor: nesw-resize; -} -.tray-move-handle { - position: fixed; - top: 0; - left: 14px; - right: 14px; - height: 12px; - z-index: 49; - display: flex; - align-items: center; - justify-content: center; - cursor: move; -} -.tray-move-handle__grip { - width: 32px; - height: 3px; - border-radius: 2px; - background: rgba(255, 255, 255, 0.16); - transition: background 120ms ease-out; -} -.tray-move-handle:hover .tray-move-handle__grip { - background: rgba(255, 255, 255, 0.36); -} -[data-theme="light"] .tray-move-handle__grip { - background: rgba(0, 0, 0, 0.14); -} -[data-theme="light"] .tray-move-handle:hover .tray-move-handle__grip { - background: rgba(0, 0, 0, 0.32); -} -/* Keep the tray surface's first row clear of the move strip. */ .tray-panel-reveal .menu-surface--tray { - padding-top: 12px; + flex-grow: 1; } - -.shell--tray-panel { - max-width: 320px; - padding: 8px 10px 12px; +.tray-panel-reveal .menu-surface--tray .menu-surface__footer { + margin-top: auto; } /* spin animation for refresh */ @@ -3529,20 +3378,116 @@ html:has(.menu-surface--tray) { overflow: visible; } -/* Tray variant — auto-sized by JS, no forced min-height. */ .menu-surface--tray { - min-height: 0; - border-radius: 10px; - overflow: clip; - border: 1px solid rgba(255, 255, 255, 0.10); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); - padding: 4px 0; + --mac-font: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; + --mac-text-headline: 600 13px/16px var(--mac-font); + --mac-text-body-medium: 500 13px/16px var(--mac-font); + --mac-text-subheadline: 400 11px/14px var(--mac-font); + --mac-text-footnote: 400 10px/13px var(--mac-font); + --mac-text-caption: 400 10px/13px var(--mac-font); + --mac-text-section: 600 10px/13px var(--mac-font); + --mac-text-caption2: 400 10px/13px var(--mac-font); + --mac-text-menu: 400 13px/16px var(--mac-font); + --mac-text-shortcut: 400 13px/16px var(--mac-font); + + --mac-panel-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.25); + --mac-panel-border: rgba(0, 0, 0, 0.38); + --mac-text: rgba(0, 0, 0, 0.85); + --mac-text-2: rgba(0, 0, 0, 0.5); + --mac-text-3: rgba(0, 0, 0, 0.26); + --mac-track: rgba(0, 0, 0, 0.057); + --mac-divider: rgba(0, 0, 0, 0.15); + --mac-separator: rgba(0, 0, 0, 0.1); + --mac-error: #ff3b30; + --mac-pace-ok: #34c759; + --mac-pace-bad: #ff3b30; + --mac-marker: rgba(0, 0, 0, 0.58); + --mac-accent: #007aff; + --mac-selection-bg: #0078d4; + --mac-selection-text: #fff; + + --mac-panel-width: 310px; + --mac-panel-radius: 6px; + --mac-bar-h: 6px; + --mac-bar-r: 3px; + --mac-pace-punch: 6px; + --mac-pace-stripe: 2px; + --mac-warn-punch: 5px; + --mac-warn-stripe: 1px; + --mac-metric-card-bg: rgba(0, 0, 0, 0.04); + --mac-metric-card-r: 10px; + --mac-icon-btn-r: 4px; + --mac-chart-h: 58px; + --mac-chart-bar-r: 1.5px; + --mac-sel-inset-x: 6px; + --mac-sel-inset-y: 2px; + --mac-sel-r: 6px; + --mac-sel-fade: 60ms; + --mac-sel-r-refresh: 7px; + + --mac-pad-x: 20px; + --mac-pad-header-y: 6px; + --mac-gap-header: 6px; + --mac-section-pt: 6px; + --mac-usage-pt: 10px; + --mac-section-pb: 6px; + --mac-header-line-gap: 4px; + --mac-header-col-gap: 12px; + --mac-after-divider: 16px; + --mac-metric-gap: 12px; + --mac-metric-inner: 6px; + --mac-inline-gap: 8px; + --mac-kv-section: 6px; + --mac-kv-row: 3px; + --mac-kv-value: 1px; + --mac-kv-gap: 12px; + --mac-dash-gap: 10px; + --mac-kpi-gap: 6px; + --mac-row-h: 24px; + --mac-menu-row-h: 22px; + --mac-row-pl: 15px; + --mac-row-icon: 16px; + --mac-row-icon-gap: 6px; + --mac-shortcut-w: 44px; + + --font-subheadline: 11px; + --font-footnote: 10px; + --font-caption: 10px; + + --menu-popover-bg: var(--mac-panel-bg); + --text-primary: var(--mac-text); + --text-secondary: var(--mac-text-2); + --text-muted: var(--mac-text-2); + --panel-border: var(--mac-divider); + + --usage-bar-track: var(--mac-track); + --pace-slow-bg: rgba(0, 122, 255, 0.12); + --pace-slow-fg: #007aff; + --pace-steady-bg: rgba(21, 128, 61, 0.12); + --pace-steady-fg: #15803d; + --pace-racing-bg: rgba(180, 83, 9, 0.14); + --pace-racing-fg: #b45309; + --pace-burning-bg: rgba(185, 28, 28, 0.12); + --pace-burning-fg: #b91c1c; + --pace-track-expected-fg: rgba(0, 122, 255, 0.45); + --chart-cost-fg: #007aff; + --chart-credits-fg: var(--chart-credits); + + color-scheme: light; display: flex; flex-direction: column; - /* Auto-size to content; JS resizes the Tauri window to match. - No max-height here — JS sets an explicit pixel cap after measuring. */ - height: auto; + gap: 0; box-sizing: border-box; + min-width: 0; + min-height: 0; + height: auto; + padding: 4px 0; + overflow: clip; + border: 1px solid var(--mac-panel-border); + border-radius: var(--mac-panel-radius); + background: var(--mac-panel-bg); + box-shadow: var(--mac-panel-highlight); + color: var(--mac-text); } .menu-surface--tray .menu-surface__body { flex: 0 1 auto; @@ -3579,29 +3524,37 @@ html:has(.menu-surface--tray) { box-sizing: border-box; overflow: visible; } -/* Selected card — subtle highlight like macOS active provider */ .menu-surface--tray .menu-stack__item--selected { - background: rgba(255, 255, 255, 0.06); border: none; border-radius: 6px; padding: 4px 4px; } -/* Tray separators — subtle hairlines matching macOS card dividers */ -.menu-surface--tray .menu-stack__sep { - height: 0.5px; - margin: 2px 16px; - background: rgba(255, 255, 255, 0.08); +.menu-surface--tray .menu-card__copy-btn:hover { + background: rgba(0, 0, 0, 0.06); } -[data-theme="light"] .menu-surface--tray .menu-stack__sep { - background: rgba(0, 0, 0, 0.08); +.menu-surface--tray .provider-grid__item .provider-icon { + filter: brightness(0); } -[data-theme="light"] .menu-surface--tray .menu-stack__item { - background: rgba(0, 0, 0, 0.03); - border: 0.5px solid rgba(0, 0, 0, 0.04); +.menu-surface--tray .provider-grid__item--more .provider-grid__icon-overview { + background: rgba(0, 0, 0, 0.06); } -[data-theme="light"] .menu-surface--tray .menu-stack__item--selected { +.menu-surface--tray .provider-grid__item--more:hover .provider-grid__icon-overview { + background: rgba(0, 0, 0, 0.1); +} +.menu-surface--tray .update-banner__action:hover { background: rgba(0, 0, 0, 0.06); - border: none; +} +.menu-surface--tray .update-banner--available, +.menu-surface--tray .update-banner--ready { + color: var(--provider-status-ok); +} +.menu-surface--tray .credential-btn { + border-color: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.05); +} +.menu-surface--tray .credential-btn:hover:not(:disabled) { + border-color: rgba(0, 0, 0, 0.18); + background: rgba(0, 0, 0, 0.08); } /* Tray grid — flush with no background container, matching macOS */ .menu-surface--tray .provider-grid { @@ -3627,12 +3580,6 @@ html:has(.menu-surface--tray) { .menu-surface--tray .provider-grid--sparse .provider-grid__label { display: none; } -/* Divider between provider grid and card stack */ -.menu-surface--tray .provider-grid__divider { - height: 1px; - margin: 2px 0; - background: rgba(255, 255, 255, 0.08); -} /* Tray font sizing — match macOS: .headline=13pt, .footnote=10pt */ /* macOS v0.32.2: H padding 20, header-only V padding 7, section V padding 6 */ @@ -3726,52 +3673,6 @@ html:has(.menu-surface--tray) { display: none; } -/* ── Context actions (detail mode) ────────────────────────────────── - Matches macOS actionsSection: "Usage Dashboard" + "Status Page" - with SF Symbol–style icons. Shown only in detail (single provider) mode. */ -.context-actions { - display: flex; - flex-direction: column; - gap: 1px; - flex: 0 0 auto; - margin-top: 4px; - padding: 0 10px; -} -.context-actions__divider { - height: 1px; - margin: 4px 0; - background: rgba(255, 255, 255, 0.10); -} -[data-theme="light"] .context-actions__divider { - background: rgba(0, 0, 0, 0.10); -} -.context-actions__btn { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 2px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--text-primary, #f5f5f7); - font-size: 12px; - cursor: pointer; - text-align: left; -} -.context-actions__btn:hover { - background: rgba(255, 255, 255, 0.08); -} -[data-theme="light"] .context-actions__btn:hover { - background: rgba(0, 0, 0, 0.06); -} -.context-actions__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - font-size: 13px; -} - /* Tray card content — match macOS spacing values from SwiftUI source */ .menu-surface--tray .menu-card__divider { margin: 3px 0; @@ -3818,11 +3719,6 @@ html:has(.menu-surface--tray) { font-size: 10px; line-height: 1.25; } -/* Hide cost section in tray — macOS compact popover only shows usage metrics */ -.menu-surface--tray .menu-card__cost, -.menu-surface--tray .menu-card__content > .menu-card__divider:has(+ .menu-card__cost) { - display: none; -} .menu-card__local-grid { display: grid; @@ -3869,38 +3765,6 @@ html:has(.menu-surface--tray) { line-height: 1.3; } -/* Tray footer — match macOS NSMenuItem native look (10pt, ~16px row height) */ -.menu-surface--tray .menu-surface__footer { - padding: 4px 0 3px; - margin-top: 2px; - border-top: 0.5px solid rgba(255, 255, 255, 0.12); -} -.menu-surface--tray .menu-surface__footer-row { - padding: 3px 16px; - font-size: 11px; - line-height: 1.3; - min-height: 18px; -} -/* Indent must override the tray padding above so iconless rows (Settings / - About / Quit) align with the iconified Refresh row. */ -.menu-surface--tray .menu-surface__footer-row--no-icon { - padding-left: calc(16px + 14px + 8px); -} -.menu-surface--tray .menu-surface__footer-icon { - font-size: 11px; - width: 14px; - opacity: 0.7; -} -/* Zoom row keeps a taller (16px) slider hit area even though the tray - footer's row padding/font-size are otherwise compacted. */ -.menu-surface--tray .menu-surface__footer-zoom { - padding-top: 4px; - padding-bottom: 4px; -} -.menu-surface--tray .menu-surface__footer-zoom-value { - font-size: 10px; -} - /* ── Provider card — direct mirror of UsageMenuCardView ─────────────── */ .menu-card { @@ -4625,46 +4489,6 @@ html:has(.menu-surface--tray) { scrollbar-color: rgba(255, 255, 255, 0.18) transparent; } -/* ── Menu surface: rounded NSMenu-style panel matching macOS popover ── */ -.menu-surface { - width: 100%; - min-width: 260px; - background: var(--menu-popover-bg); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 10px; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 1px rgba(0, 0, 0, 0.3); - font-size: var(--font-body); - line-height: var(--line-tight); -} -/* Re-assert tray overrides after the second .menu-surface rule (cascade order) */ -.menu-surface--tray { - border: 1px solid rgba(255, 255, 255, 0.10); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); - overflow: hidden; - /* #265: TrayPanel applies CSS `zoom` on this surface, so it lays out in - windowWidth/zoom local px; the 260px `min-width` floor from .menu-surface - would be multiplied by the zoom on render (→ 390px at 150%) and force a - horizontal scrollbar in the 328px tray window. The window's own 300px - minimum already covers the floor — let the zoomed surface wrap instead. */ - min-width: 0; -} -.menu-surface--tray { - border: 1px solid rgba(255, 255, 255, 0.10); - border-radius: 10px; - overflow: hidden; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); -} -[data-theme="light"] .menu-surface { - background: var(--menu-popover-bg); - border: 1px solid rgba(0, 0, 0, 0.15); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18), 0 0 1px rgba(0, 0, 0, 0.12); -} -[data-theme="light"] .menu-surface--tray { - border: none; - box-shadow: none; -} - .menu-surface__header { min-height: 20px; padding: 0; @@ -4690,148 +4514,85 @@ html:has(.menu-surface--tray) { font-size: 13px; } -/* Footer of native-style menu rows (Settings… / About / Quit). - Sits flush with the surface's 10/6 padding and reads like an NSMenu - item: full-width row, 18px icon column, subtle hover highlight. */ .menu-surface__footer { display: flex; flex-direction: column; - gap: 0; - padding: 8px 0 4px; - margin-top: 0; - border-top: 1px solid rgba(255, 255, 255, 0.12); flex-shrink: 0; + margin: 0; + padding: 0; + border-top: none; +} +.menu-surface__footer-sep { + height: 1px; + margin: 5px 14px; + background: var(--mac-separator); } .menu-surface__footer-row { + position: relative; + isolation: isolate; display: flex; align-items: center; - gap: 8px; + gap: var(--mac-row-icon-gap); width: 100%; - padding: 4px 6px; + min-height: var(--mac-menu-row-h); + padding: 0 var(--mac-row-pl); border: 0; - border-radius: 4px; background: transparent; - color: var(--text-primary); - font-size: var(--font-body); - font-weight: 400; + color: var(--mac-text); + font: var(--mac-text-menu); text-align: left; cursor: pointer; - transition: background 0.08s ease; } -/* Footer rows without an SF Symbol icon (Settings / About / Quit on - macOS): indent by the same 18px the icon column would occupy so the - text aligns with iconified rows above. */ -.menu-surface__footer-row--no-icon { - padding-left: calc(6px + 18px + 8px); +.menu-surface__footer-row--refresh { + min-height: var(--mac-row-h); +} +.menu-surface__footer-row::before { + content: ""; + position: absolute; + inset: var(--mac-sel-inset-y) var(--mac-sel-inset-x); + z-index: -1; + border-radius: var(--mac-sel-r); + background: transparent; + transition: background-color var(--mac-sel-fade) ease-out; +} +.menu-surface__footer-row--refresh::before { + inset: 0 5px; + border-radius: var(--mac-sel-r-refresh); } -.menu-surface__footer-row:hover, +.menu-surface__footer-row:hover:not(:disabled), .menu-surface__footer-row:focus-visible { - background: rgba(255, 255, 255, 0.07); - color: var(--text-primary); + color: var(--mac-selection-text); outline: none; } -[data-theme="light"] .menu-surface__footer-row:hover, -[data-theme="light"] .menu-surface__footer-row:focus-visible { - background: rgba(0, 0, 0, 0.06); +.menu-surface__footer-row:hover:not(:disabled)::before, +.menu-surface__footer-row:focus-visible::before { + background: var(--mac-selection-bg); } -.menu-surface__footer-row .menu-surface__footer-icon { - width: 18px; +.menu-surface__footer-row:disabled { + color: var(--mac-text-3); + cursor: default; +} +.menu-surface__footer-icon { display: inline-flex; - justify-content: center; - font-size: 12px; - color: var(--text-secondary); + flex-shrink: 0; + width: var(--mac-row-icon); + height: var(--mac-row-icon); } -.menu-surface__footer-row:hover .menu-surface__footer-icon, -.menu-surface__footer-row:focus-visible .menu-surface__footer-icon { - color: var(--text-primary); +.menu-surface__footer-icon svg { + width: 100%; + height: 100%; } .menu-surface__footer-shortcut { + min-width: var(--mac-shortcut-w); margin-left: auto; - font-size: var(--font-caption2); - color: var(--text-muted); + color: var(--mac-text-3); + font: var(--mac-text-shortcut); font-variant-numeric: tabular-nums; -} -.menu-surface__footer-row:hover .menu-surface__footer-shortcut, -.menu-surface__footer-row:focus-visible .menu-surface__footer-shortcut { - color: var(--text-secondary); -} - -/* Tray flyout zoom row — sits above Refresh in the footer. A plain `div` - (not a nav button), so it opts out of the button hover highlight and - instead hosts a Win11-volume-style slider with a fixed-width % readout - on the right (tabular nums + fixed width = no jitter while dragging). */ -.menu-surface__footer-zoom { - cursor: default; -} -.menu-surface__footer-zoom:hover, -.menu-surface__footer-zoom:focus-visible { - background: transparent; -} -.menu-surface__footer-zoom-value { - width: 34px; - flex-shrink: 0; text-align: right; - font-size: var(--font-caption2); - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} -/* Win11-style range input: thin rounded 4px track, accent fill left of the - thumb (painted via the `--zoom-fill` custom property set inline from - React — WebKit has no ::fill pseudo), round accent thumb. WebView2 is - Chromium-only, so ::-webkit-* pseudos are sufficient. */ -.menu-surface__footer-zoom-slider { - -webkit-appearance: none; - appearance: none; - flex: 1 1 auto; - min-width: 0; - height: 16px; - margin: 0 2px; - padding: 0; - background: transparent; - cursor: pointer; -} -.menu-surface__footer-zoom-slider::-webkit-slider-runnable-track { - height: 4px; - border-radius: 2px; - background: linear-gradient( - to right, - var(--accent, #0a84ff) 0%, - var(--accent, #0a84ff) var(--zoom-fill, 0%), - rgba(255, 255, 255, 0.2) var(--zoom-fill, 0%) - ); -} -.menu-surface__footer-zoom-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 12px; - height: 12px; - margin-top: -4px; /* centres the 12px thumb on the 4px track */ - border-radius: 50%; - background: var(--accent, #0a84ff); - box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.35), - 0 1px 2px rgba(0, 0, 0, 0.4); -} -.menu-surface__footer-zoom-slider:focus-visible { - outline: none; -} -.menu-surface__footer-zoom-slider:focus-visible::-webkit-slider-thumb { - box-shadow: - 0 0 0 2px var(--accent-muted, rgba(10, 132, 255, 0.25)), - 0 0 0 1px rgba(255, 255, 255, 0.35); } -[data-theme="light"] - .menu-surface__footer-zoom-slider::-webkit-slider-runnable-track { - background: linear-gradient( - to right, - var(--accent, #007aff) 0%, - var(--accent, #007aff) var(--zoom-fill, 0%), - rgba(0, 0, 0, 0.15) var(--zoom-fill, 0%) - ); -} -[data-theme="light"] .menu-surface__footer-zoom-slider::-webkit-slider-thumb { - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.15), - 0 1px 2px rgba(0, 0, 0, 0.2); +.menu-surface__footer-row:hover:not(:disabled) .menu-surface__footer-shortcut, +.menu-surface__footer-row:focus-visible .menu-surface__footer-shortcut { + color: var(--mac-selection-text); } /* ── Provider icon grid ────────────────────────────────────────────── @@ -5838,14 +5599,6 @@ html:has(.menu-surface--tray) { padding-bottom: 6px; } -.menu-surface__footer-icon { - opacity: 0.5; -} -.menu-surface__footer-row:hover .menu-surface__footer-icon, -.menu-surface__footer-row:focus-visible .menu-surface__footer-icon { - opacity: 0.8; -} - /* ── Provider tab status dots removed (replaced by .provider-grid__dot) */ /* ── Plan badge pill ────────────────────────────────────────────────── */ diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx index 0f6263c089..a1d83f9f69 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx @@ -22,8 +22,15 @@ const tauriMocks = vi.hoisted(() => ({ getWorkAreaRect: vi.fn(), reanchorTrayPanel: vi.fn(), revealTrayPanelWindow: vi.fn(), - flyoutStoredSize: vi.fn().mockResolvedValue(null), - setFlyoutSize: vi.fn().mockResolvedValue(undefined), + getAppInfo: vi.fn().mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "1", + updateChannel: "stable", + tagline: "", + }), + getSystemAccentColor: vi.fn().mockResolvedValue(null), + getProviderDetail: vi.fn().mockResolvedValue({ dashboardUrl: null, statusPageUrl: null }), openProviderDashboard: vi.fn(), openProviderStatusPage: vi.fn(), getProviderChartData: vi.fn(), @@ -43,9 +50,7 @@ const windowMocks = vi.hoisted(() => ({ getCurrentWindow: vi.fn(() => ({ setSize: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), })), LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 0556b6466d..19d69ad9a8 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -1,4 +1,12 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -23,9 +31,9 @@ const tauriMocks = vi.hoisted(() => ({ getWorkAreaRect: vi.fn(), reanchorTrayPanel: vi.fn(), revealTrayPanelWindow: vi.fn(), - flyoutStoredSize: vi.fn().mockResolvedValue(null), - setFlyoutSize: vi.fn().mockResolvedValue(undefined), - resetFlyoutPosition: vi.fn().mockResolvedValue(undefined), + getAppInfo: vi.fn(), + getSystemAccentColor: vi.fn(), + getProviderDetail: vi.fn(), openProviderDashboard: vi.fn(), openProviderStatusPage: vi.fn(), getProviderChartData: vi.fn(), @@ -43,17 +51,11 @@ const eventMocks = vi.hoisted(() => ({ })); const windowMocks = vi.hoisted(() => ({ - startDragging: vi.fn().mockResolvedValue(undefined), - startResizeDragging: vi.fn().mockResolvedValue(undefined), // Loosely typed so tests can swap in windows with only the methods they use. getCurrentWindow: vi.fn((): Record => ({ - startDragging: windowMocks.startDragging, - startResizeDragging: windowMocks.startResizeDragging, setSize: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), })), LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), @@ -229,6 +231,13 @@ function renderTrayPanel( ); } +function providerLinks(id: string) { + return { + dashboardUrl: `https://${id}.example/usage`, + statusPageUrl: `https://status.${id}.example`, + }; +} + function emitEvent(event: string, payload: unknown) { for (const listener of eventMocks.listeners.get(event) ?? []) { listener({ payload }); @@ -242,11 +251,22 @@ describe("TrayPanel provider grid", () => { tauriMocks.claudeReconciliationState.mockResolvedValue(null); tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); tauriMocks.getUsageSpendSummary.mockResolvedValue({ rows: [], models: [] }); - tauriMocks.flyoutStoredSize.mockResolvedValue(null); tauriMocks.beginFlyoutGesture.mockResolvedValue(undefined); - tauriMocks.resetFlyoutPosition.mockResolvedValue(undefined); - windowMocks.startDragging.mockResolvedValue(undefined); - windowMocks.startResizeDragging.mockResolvedValue(undefined); + tauriMocks.getAppInfo.mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "1", + updateChannel: "stable", + tagline: "", + }); + tauriMocks.getSystemAccentColor.mockResolvedValue(null); + tauriMocks.getProviderDetail.mockResolvedValue({ + dashboardUrl: null, + statusPageUrl: null, + }); + tauriMocks.openProviderDashboard.mockResolvedValue(undefined); + tauriMocks.openProviderStatusPage.mockResolvedValue(undefined); + tauriMocks.openSettingsWindow.mockResolvedValue(undefined); tauriMocks.refreshProviders.mockResolvedValue(undefined); tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); tauriMocks.dismissTrayPanel.mockResolvedValue(undefined); @@ -285,17 +305,19 @@ describe("TrayPanel provider grid", () => { ActionRefresh: "Refresh", OverviewSpendProviderCoverage: "{} of {} providers have spend", MenuAbout: "About CodexBar", + MenuAboutVersion: "About CodexBar (v{})", MenuQuit: "Quit", MenuSettings: "Settings...", - ActionUsageDashboard: "Usage Dashboard", - ActionStatusPage: "Status Page", + PanelMenu: "Menu", + TrayMenuSwitchAccount: "Switch Account...", + TrayMenuUsageDashboard: "Usage Dashboard", + TrayMenuStatusPage: "Status Page", PanelAllProviders: "All providers", PanelAllProvidersShort: "All", PanelLeftSuffix: "left", PanelShowAllProviders: "Show all providers", PanelShowFewerProviders: "Show fewer providers", PanelUsedSuffix: "used", - PanelZoom: "Zoom", }), ); eventMocks.listen.mockImplementation( @@ -315,13 +337,6 @@ describe("TrayPanel provider grid", () => { }); it("reveals regardless of the shared surface-mode snapshot (TrayPanel now runs in its own dedicated window)", async () => { - // TrayPanel is now hosted exclusively in the dedicated `flyout` OS - // window (see App.tsx's isFlyoutWindow() routing), so it must not depend - // on `main`'s surface-mode machine to know it's "open" — that machine - // can report something other than "trayPanel". Overriding the snapshot - // mock to another mode confirms the fixed-size restore + reveal gate - // (isFlyoutOpen, hardcoded true in TrayPanel.tsx) is no longer wired to - // useSurfaceMode() at all. tauriMocks.getCurrentSurfaceState.mockResolvedValue({ mode: "settings", target: { kind: "settings", tab: "general" }, @@ -440,7 +455,10 @@ describe("TrayPanel provider grid", () => { }); }); - it("scopes the status-page action to the selected provider", async () => { + it("offers the selected provider's Usage Dashboard and Status Page rows", async () => { + tauriMocks.getProviderDetail.mockImplementation((id: string) => + Promise.resolve(providerLinks(id)), + ); const { container } = renderTrayPanel([ provider("claude", "Claude", 35), provider("codex", "Codex", 45), @@ -449,20 +467,83 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(container.querySelector(".tray-panel-reveal--ready")).not.toBeNull(); }); + expect(screen.queryByRole("button", { name: "Usage Dashboard" })).toBeNull(); + expect(container.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: /^Claude$/ })); - fireEvent.click(await screen.findByRole("button", { name: /^Usage Dashboard$/ })); + fireEvent.click(await screen.findByRole("button", { name: "Usage Dashboard" })); expect(tauriMocks.openProviderDashboard).toHaveBeenLastCalledWith("claude"); - fireEvent.click(await screen.findByRole("button", { name: /^Status Page$/ })); + fireEvent.click(screen.getByRole("button", { name: "Status Page" })); expect(tauriMocks.openProviderStatusPage).toHaveBeenLastCalledWith("claude"); + expect(container.querySelectorAll(".menu-surface__footer-sep")).toHaveLength(2); fireEvent.click(screen.getByRole("button", { name: /^Codex$/ })); - fireEvent.click(await screen.findByRole("button", { name: /^Usage Dashboard$/ })); + fireEvent.click(await screen.findByRole("button", { name: "Usage Dashboard" })); expect(tauriMocks.openProviderDashboard).toHaveBeenLastCalledWith("codex"); - fireEvent.click(await screen.findByRole("button", { name: /^Status Page$/ })); + fireEvent.click(screen.getByRole("button", { name: "Status Page" })); expect(tauriMocks.openProviderStatusPage).toHaveBeenLastCalledWith("codex"); }); + it("offers the only provider's rows without selecting its tab", async () => { + tauriMocks.getProviderDetail.mockImplementation((id: string) => + Promise.resolve(providerLinks(id)), + ); + renderTrayPanel([provider("codex", "Codex", 45)]); + + fireEvent.click(await screen.findByRole("button", { name: "Usage Dashboard" })); + + expect(tauriMocks.openProviderDashboard).toHaveBeenCalledWith("codex"); + }); + + it("leaves out a link the provider does not have", async () => { + tauriMocks.getProviderDetail.mockResolvedValue({ + dashboardUrl: "https://claude.example/usage", + statusPageUrl: null, + }); + renderTrayPanel([provider("claude", "Claude", 35)]); + + expect( + await screen.findByRole("button", { name: "Usage Dashboard" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Status Page" })).toBeNull(); + }); + + it("never shows the previous provider's links while the next one loads", async () => { + let resolveCodex: (detail: ReturnType) => void = () => {}; + tauriMocks.getProviderDetail.mockImplementation((id: string) => + id === "codex" + ? new Promise((resolve) => { + resolveCodex = resolve; + }) + : Promise.resolve(providerLinks(id)), + ); + renderTrayPanel([provider("claude", "Claude", 35), provider("codex", "Codex", 45)]); + + fireEvent.click(await screen.findByRole("button", { name: /^Claude$/ })); + expect( + await screen.findByRole("button", { name: "Usage Dashboard" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^Codex$/ })); + expect(screen.queryByRole("button", { name: "Usage Dashboard" })).toBeNull(); + + await act(async () => { + resolveCodex(providerLinks("codex")); + }); + fireEvent.click(screen.getByRole("button", { name: "Usage Dashboard" })); + expect(tauriMocks.openProviderDashboard).toHaveBeenLastCalledWith("codex"); + }); + + it("offers Switch Account for a Claude account with CLI quota", async () => { + renderTrayPanel([ + { ...provider("claude", "Claude", 35), hasSuccessfulClaudeCliQuota: true }, + ]); + + fireEvent.click(await screen.findByRole("button", { name: "Switch Account..." })); + + expect(tauriMocks.openSettingsWindow).toHaveBeenCalledWith("providers"); + }); + it("localizes static tray panel labels in Japanese", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( buildBundle( @@ -479,7 +560,7 @@ describe("TrayPanel provider grid", () => { PanelPeriodTokens: "{}間のトークン", PanelTopModelPrefix: "トップモデル", PanelEstimatedFromLocalLogs: "ローカルログから推定", - PanelZoom: "ズーム", + MenuAboutVersion: "CodexBar について (v{})", UpdatedDaysAgo: "{}日前", }, "japanese", @@ -512,11 +593,9 @@ describe("TrayPanel provider grid", () => { ).not.toBeNull(); }); expect(container.querySelector(".provider-grid__item")?.textContent).toContain("すべて"); - expect(screen.getByText("ズーム")).toBeInTheDocument(); - expect(screen.getByLabelText("ズーム")).toBeInTheDocument(); expect(screen.getByText("更新")).toBeInTheDocument(); expect(screen.getByText("設定...")).toBeInTheDocument(); - expect(screen.getByText("CodexBar について")).toBeInTheDocument(); + expect(await screen.findByText("CodexBar について (v0.70.0)")).toBeInTheDocument(); expect(screen.getByText("終了")).toBeInTheDocument(); expect(await screen.findByText("30日間のコスト")).toBeInTheDocument(); expect(container.querySelector(".menu-card__subtitle")?.textContent).toContain("日前"); @@ -664,58 +743,6 @@ describe("TrayPanel provider grid", () => { expect(container.querySelectorAll(".menu-metric")).toHaveLength(3); }); - it("uses independent columns for a wide user-sized overview", async () => { - tauriMocks.flyoutStoredSize.mockResolvedValue([700, 700]); - const providers = [ - provider("codex", "Codex"), - provider("claude", "Claude"), - provider("antigravity", "Antigravity"), - provider("copilot", "GitHub Copilot"), - ]; - - const { container } = renderTrayPanel(providers, { - enabledProviders: providers.map((snapshot) => snapshot.providerId), - }); - - await waitFor(() => { - expect(container.querySelector(".tray-panel-reveal--usersized")).not.toBeNull(); - }); - - expect( - Array.from(container.querySelectorAll(".menu-stack__column")).map((column) => - Array.from(column.querySelectorAll(".menu-stack__item")).map( - (item) => item.id, - ), - ), - ).toEqual([ - ["card-codex", "card-antigravity"], - ["card-claude", "card-copilot"], - ]); - expect(container.querySelector(".menu-stack__sep")).toBeNull(); - }); - - it("keeps the stacked layout when the saved flyout width is narrow", async () => { - vi.spyOn(window, "innerWidth", "get").mockReturnValue(700); - tauriMocks.flyoutStoredSize.mockResolvedValue([500, 700]); - const providers = [ - provider("codex", "Codex"), - provider("claude", "Claude"), - provider("antigravity", "Antigravity"), - provider("copilot", "GitHub Copilot"), - ]; - - const { container } = renderTrayPanel(providers, { - enabledProviders: providers.map((snapshot) => snapshot.providerId), - }); - - await waitFor(() => { - expect(container.querySelector(".tray-panel-reveal--usersized")).not.toBeNull(); - }); - - expect(container.querySelector(".menu-stack__column")).toBeNull(); - expect(container.querySelectorAll(".menu-stack__sep")).toHaveLength(3); - }); - it("collapses and expands the full provider catalog in the dense tray grid", async () => { const providers = TEST_PROVIDER_CATALOG.map(([id, displayName], index) => provider(id, displayName, (index * 7) % 100), @@ -846,20 +873,15 @@ describe("TrayPanel provider grid", () => { }); it("renders the default tray panel layout with no legacy window chrome", async () => { - // Pins the one dashboard layout: tray-variant surface, icon-first - // provider switcher, and the Zoom / Refresh / Settings... / About / Quit - // footer. The retired PopOut layout had a "CodexBar" title bar with - // window controls and a Settings / About / Quit footer without Zoom or - // Refresh. const { container } = renderTrayPanel([ provider("claude", "Claude", 35), provider("codex", "Codex", 20), ]); - await waitFor(() => { - expect(container.querySelector(".menu-surface__footer-zoom")).not.toBeNull(); - expect(container.querySelector(".provider-grid")).not.toBeNull(); - }); + const menu = await screen.findByRole("navigation", { name: "Menu" }); + expect( + await within(menu).findByRole("button", { name: "About CodexBar (v0.70.0)" }), + ).toBeInTheDocument(); const surface = container.querySelector(".menu-surface"); expect(surface?.classList.contains("menu-surface--tray")).toBe(true); @@ -867,221 +889,57 @@ describe("TrayPanel provider grid", () => { expect(container.querySelector(".popout-titlebar")).toBeNull(); expect(container.querySelector(".popout-scale-shell")).toBeNull(); expect(container.querySelector(".provider-grid")).not.toBeNull(); - - const footerLabels = Array.from( - container.querySelectorAll(".menu-surface__footer > *"), - ).map((el) => el.textContent ?? ""); - expect(footerLabels[0]).toContain("Zoom"); - expect(footerLabels.slice(1).map((label) => label.replace(/Ctrl\+.*/, ""))).toEqual([ - "↻Refresh", - "⚙Settings...", - "ⓘAbout CodexBar", - "⌧Quit", + expect( + within(menu) + .getAllByRole("button") + .map((row) => [row.textContent, row.getAttribute("aria-keyshortcuts")]), + ).toEqual([ + ["RefreshCtrl+R", "Control+R"], + ["Settings...Ctrl+,", "Control+,"], + ["About CodexBar (v0.70.0)", null], + ["QuitCtrl+Q", "Control+Q"], ]); expect(tauriMocks.setSurfaceMode).not.toHaveBeenCalled(); }); - it("offers a move strip and resize grips on every edge and corner", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - - await waitFor(() => { - expect(container.querySelector(".tray-move-handle")).not.toBeNull(); - }); - - const edges = Array.from(container.querySelectorAll(".tray-resize")).map((grip) => - Array.from(grip.classList).find((name) => name.startsWith("tray-resize--")), - ); - expect(edges.sort()).toEqual([ - "tray-resize--bottom", - "tray-resize--bottomleft", - "tray-resize--bottomright", - "tray-resize--left", - "tray-resize--right", - "tray-resize--top", - "tray-resize--topleft", - "tray-resize--topright", - ]); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("starts a native resize toward the grabbed corner without arming the blur guard", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => { - expect(container.querySelector(".tray-resize--bottomright")).not.toBeNull(); - }); - - fireEvent.mouseDown(container.querySelector(".tray-resize--bottomright")!); - - await waitFor(() => { - expect(windowMocks.startResizeDragging).toHaveBeenCalledWith("SouthEast"); - }); - // The backend keeps the panel open while the button is held on it; a - // guard would also swallow the next real outside click. - expect(tauriMocks.beginFlyoutGesture).not.toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("moves the flyout from the strip and puts it back by the tray on double-click", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => { - expect(container.querySelector(".tray-move-handle")).not.toBeNull(); - }); - const strip = container.querySelector(".tray-move-handle")!; - - fireEvent.mouseDown(strip, { button: 0, detail: 1 }); - await waitFor(() => { - expect(windowMocks.startDragging).toHaveBeenCalledTimes(1); - }); - expect(tauriMocks.beginFlyoutGesture).not.toHaveBeenCalled(); - - fireEvent.mouseDown(strip, { button: 0, detail: 2 }); - await waitFor(() => { - expect(tauriMocks.resetFlyoutPosition).toHaveBeenCalledTimes(1); - }); - expect(windowMocks.startDragging).toHaveBeenCalledTimes(1); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - function captureWindowEvents(scale: number) { - const handlers: { - resized?: (event: { payload: { width: number; height: number } }) => void; - scaleChanged?: (event: { - payload: { scaleFactor: number; size: { width: number; height: number } }; - }) => void; - } = {}; - windowMocks.getCurrentWindow.mockReturnValue({ - startDragging: windowMocks.startDragging, - startResizeDragging: windowMocks.startResizeDragging, - setSize: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(scale), - innerSize: vi.fn().mockResolvedValue({ width: 656, height: 400 }), - onResized: vi.fn((handler) => { - handlers.resized = handler; - return Promise.resolve(() => {}); - }), - onScaleChanged: vi.fn((handler) => { - handlers.scaleChanged = handler; - return Promise.resolve(() => {}); - }), - }); - return handlers; - } - - it("remembers a user resize in logical px and re-anchors at the new size", async () => { - const handlers = captureWindowEvents(2); - const { unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => expect(handlers.resized).toBeDefined()); - // Let the first auto-fit pass and its trailing guard settle. - await new Promise((resolve) => setTimeout(resolve, 500)); - tauriMocks.reanchorTrayPanel.mockClear(); - - act(() => { - handlers.resized!({ payload: { width: 900, height: 1500 } }); + it("applies the saved Panel scale and the Windows accent to the panel", async () => { + tauriMocks.getSystemAccentColor.mockResolvedValue("#c42b1c"); + const { container } = renderTrayPanel([provider("claude", "Claude", 35)], { + trayScalePercent: 150, }); await waitFor(() => { - expect(tauriMocks.setFlyoutSize).toHaveBeenCalledWith(450, 750); + expect( + container + .querySelector(".menu-surface--tray") + ?.style.getPropertyValue("--mac-selection-bg"), + ).toBe("#c42b1c"); }); - expect(tauriMocks.reanchorTrayPanel).toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); + const surface = container.querySelector(".menu-surface--tray")!; + expect(surface.style.getPropertyValue("--mac-selection-text")).toBe("#fff"); + expect(surface.style.zoom).toBe("1.5"); }); - it("does not treat a DPI rescale as a user resize", async () => { - const handlers = captureWindowEvents(2); - const { unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => expect(handlers.scaleChanged).toBeDefined()); - // Let the first auto-fit pass and its trailing guard settle. - await new Promise((resolve) => setTimeout(resolve, 500)); + it("follows a new Windows accent the next time the panel takes focus", async () => { + tauriMocks.getSystemAccentColor.mockResolvedValue("#0078d4"); + const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); + const selectionBg = () => + container + .querySelector(".menu-surface--tray") + ?.style.getPropertyValue("--mac-selection-bg"); + await waitFor(() => expect(selectionBg()).toBe("#0078d4")); + tauriMocks.getSystemAccentColor.mockResolvedValue("#ffb900"); act(() => { - handlers.scaleChanged!({ - payload: { scaleFactor: 2.25, size: { width: 738, height: 1755 } }, - }); - handlers.resized!({ payload: { width: 738, height: 1755 } }); - }); - await new Promise((resolve) => setTimeout(resolve, 400)); - - expect(tauriMocks.setFlyoutSize).not.toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("ignores non-primary presses on the move strip", async () => { - const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); - await waitFor(() => { - expect(container.querySelector(".tray-move-handle")).not.toBeNull(); - }); - - fireEvent.mouseDown(container.querySelector(".tray-move-handle")!, { button: 2, detail: 1 }); - - expect(windowMocks.startDragging).not.toHaveBeenCalled(); - expect(tauriMocks.resetFlyoutPosition).not.toHaveBeenCalled(); - // Unmount while the mocks still resolve; the shared afterEach resets - // them before the automatic cleanup runs. - unmount(); - }); - - it("renders the tray footer zoom slider above Refresh and persists trayScalePercent after the debounce", async () => { - const { container } = renderTrayPanel( - [provider("claude", "Claude", 35)], - { trayScalePercent: 120 }, - ); - - await waitFor(() => { - expect(container.querySelector(".menu-surface__footer-zoom")).not.toBeNull(); + window.dispatchEvent(new Event("focus")); }); - const footerChildren = Array.from( - container.querySelectorAll(".menu-surface__footer > *"), - ); - const zoomIndex = footerChildren.findIndex((el) => - el.classList.contains("menu-surface__footer-zoom"), - ); - const refreshIndex = footerChildren.findIndex( - (el) => el.textContent?.includes("Refresh"), - ); - expect(zoomIndex).toBeGreaterThanOrEqual(0); - expect(refreshIndex).toBeGreaterThan(zoomIndex); - - // Slider reflects the persisted settings value. - const slider = container.querySelector( - ".menu-surface__footer-zoom-slider", - )!; - expect(slider).not.toBeNull(); - expect(slider.value).toBe("120"); - expect(slider.min).toBe("100"); - expect(slider.max).toBe("200"); - expect(slider.step).toBe("5"); - expect( - container.querySelector(".menu-surface__footer-zoom-value")?.textContent, - ).toBe("120%"); - - fireEvent.change(slider, { target: { value: "150" } }); - - // Live preview: thumb and readout update immediately from local state… - expect(slider.value).toBe("150"); + await waitFor(() => expect(selectionBg()).toBe("#ffb900")); expect( - container.querySelector(".menu-surface__footer-zoom-value")?.textContent, - ).toBe("150%"); - - // …while persistence trails the ~250ms debounce (not synchronous). - expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); - await waitFor(() => { - expect(tauriMocks.updateSettings).toHaveBeenCalledWith({ - trayScalePercent: 150, - }); - }); - expect(tauriMocks.updateSettings).toHaveBeenCalledTimes(1); + container + .querySelector(".menu-surface--tray")! + .style.getPropertyValue("--mac-selection-text"), + ).toBe("#000"); }); it("reveals the tray panel if the native resize pass fails", async () => { @@ -1089,9 +947,7 @@ describe("TrayPanel provider grid", () => { windowMocks.getCurrentWindow.mockReturnValue({ setSize: vi.fn().mockRejectedValue(new Error("resize failed")), close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); @@ -1099,6 +955,7 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(container.querySelector(".tray-panel-reveal--ready")).not.toBeNull(); }); + expect(warn).toHaveBeenCalledWith("CodexBar tray panel resize failed", expect.any(Error)); warn.mockRestore(); }); @@ -1108,9 +965,7 @@ describe("TrayPanel provider grid", () => { windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); @@ -1137,9 +992,7 @@ describe("TrayPanel provider grid", () => { windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const denseProviders = TEST_PROVIDER_CATALOG.slice(0, 36).map(([id, displayName]) => provider(id, displayName), @@ -1151,19 +1004,17 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 776 }), + expect.objectContaining({ width: 310, height: 776 }), ); }); }); - it("keeps provider detail mode tall enough for context actions and footer", async () => { + it("keeps provider detail mode tall enough for the menu rows and footer", async () => { const setSize = vi.fn().mockResolvedValue(undefined); windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 310, height: 200 }), }); const errorProvider = { ...provider("abacus", "Abacus AI", 0), @@ -1185,19 +1036,17 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 420 }), + expect.objectContaining({ width: 310, height: 420 }), ); }); }); - it("scales the auto-fit measure by the active tray zoom before clamping (#265)", async () => { + it("fits the window to the zoomed panel at 150% Panel scale (#265)", async () => { const setSize = vi.fn().mockResolvedValue(undefined); windowMocks.getCurrentWindow.mockReturnValue({ setSize, close: vi.fn().mockResolvedValue(undefined), - scaleFactor: vi.fn().mockResolvedValue(1), - onResized: vi.fn().mockResolvedValue(() => {}), - innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + innerSize: vi.fn().mockResolvedValue({ width: 465, height: 200 }), }); // jsdom has no layout engine (scrollHeight always reads 0), so pin it // globally to a deterministic PRE-zoom content height: TrayPanel applies @@ -1211,17 +1060,14 @@ describe("TrayPanel provider grid", () => { trayScalePercent: 150, }); - // 505 raw × 1.5 zoom = 757.5 → 758 rounded, + the 4px fudge = 762. await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 762 }), + expect.objectContaining({ width: 465, height: 759 }), ); }); first.unmount(); setSize.mockClear(); - // Same zoom, taller content: round(700 × 1.5) + 4 = 1054 exceeds the - // mocked work-area cap (900 - 16 = 884), so the clamp still wins. scrollHeight.mockReturnValue(700); renderTrayPanel([provider("codex", "Codex", 61)], { trayScalePercent: 150, @@ -1229,7 +1075,7 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ width: 328, height: 884 }), + expect.objectContaining({ width: 465, height: 884 }), ); }); }); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 82b13a5586..d9888e2ced 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -1,53 +1,20 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; -import { getCurrentWindow, type Window } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; import { costPeriodShortLabel } from "../lib/costPeriod"; import { useCurrency } from "../hooks/CurrencyProvider"; import { sumDisplayCurrencyAmounts } from "../lib/currency"; -import { - getUsageSpendSummary, - openProviderDashboard, - openProviderStatusPage, - openSettingsWindow, - resetFlyoutPosition, -} from "../lib/tauri"; -import { - TRAY_SCALE_MAX, - TRAY_SCALE_MIN, - TRAY_SCALE_STEP, - useTrayPanelController, -} from "../hooks/useTrayPanelController"; +import { getUsageSpendSummary } from "../lib/tauri"; +import { useTrayPanelController } from "../hooks/useTrayPanelController"; import { useStayAwakeStatus } from "../hooks/useStayAwakeStatus"; import MenuCard from "../components/MenuCard"; import MenuSurface, { MenuEmpty } from "../components/MenuSurface"; import UpdateBanner from "../components/UpdateBanner"; import ProviderGrid from "../components/ProviderGrid"; import AgentSessions from "../components/AgentSessions"; -import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; +import { accentSelectionStyle } from "../lib/menuSelection"; import { filterUsageSpendSummaryForOverview } from "../lib/usageSpendSharing"; -/** Provider IDs that have a dashboard URL in the backend */ -const HAS_DASHBOARD = new Set([ - "abacus", "alibaba", "alibabatokenplan", "amp", "atlascloud", "augment", - "azureopenai", "bedrock", "claude", "codex", "codebuff", - "aiand", "aixy", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", - "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", - "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate", - "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", - "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", - "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "meta", "muse", "nous", "llmman", "devpass", "xkiro", - "raycast", "vercel", -]); -/** Provider IDs that have a status page URL in the backend */ -const HAS_STATUS_PAGE = new Set([ - "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", - "claude", "codex", "copilot", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "elevenlabs", - "gemini", "grok", "groq", "kiro", "mistral", "openaiapi", - "openrouter", "vertexai", "windsurf", "xai", -]); - /** * Tray popover surface — two modes like macOS CodexBar: * 1. Overview (default): provider grid + all cards stacked @@ -61,20 +28,16 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { isRefreshing, refreshingProviderIds, hasCachedData, - trayScaleDraft, trayScale, - trayScaleFillPercent, - handleTrayScaleChange, + accentColor, sorted, gridProviders, selectedProviderId, gridExpanded, setGridExpanded, visibleProviders, - wideColumns, - useWideColumns, requestLayout, - footerRows, + footerGroups, updateState, checkNow, download, @@ -89,25 +52,10 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { revealClassName, } = useTrayPanelController(state); - const zoomRow = ( -
- {t("PanelZoom")} - handleTrayScaleChange(Number(e.target.value))} - style={{ "--zoom-fill": `${trayScaleFillPercent}%` } as CSSProperties} - /> - - {trayScaleDraft}% - -
- ); + const surfaceStyle = { + zoom: trayScale, + ...accentSelectionStyle(accentColor), + } as CSSProperties; const banner = ( provider.providerId === selectedProviderId) ?? null - : null; - const canSwitchClaudeAccount = - selectedProvider !== null && hasSuccessfulClaudeCliQuota(selectedProvider); const stayAwakeStatus = stayAwakeHeld ? (

{t("TrayStayAwakeActive")} @@ -162,13 +105,8 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { if (sorted.length === 0) { return ( -

- +
+ {stayAwakeStatus} {settings.agentSessionsEnabled && } -
); } return ( -
- +
+ {stayAwakeStatus} {settings.agentSessionsEnabled && } )}
- {useWideColumns - ? wideColumns.map((column) => ( -
p.providerId).join("|") || "empty"} - > - {column.map(renderProviderCard)} -
- )) - : visibleProviders.map((p, idx) => ( - - {idx > 0 &&
} - {renderProviderCard(p)} - - ))} + {visibleProviders.map((p, idx) => ( + + {idx > 0 &&
} + {renderProviderCard(p)} + + ))}
- {/* Context actions — detail mode only, matches macOS actionsSection */} - {selectedProviderId && - (HAS_DASHBOARD.has(selectedProviderId) || - HAS_STATUS_PAGE.has(selectedProviderId) || - canSwitchClaudeAccount) && ( -
-
- {canSwitchClaudeAccount && ( - - )} - {HAS_DASHBOARD.has(selectedProviderId) && ( - - )} - {HAS_STATUS_PAGE.has(selectedProviderId) && ( - - )} -
- )} -
); } -type ResizeDirection = Parameters[0]; - -/** Every edge and corner, so a flyout the user moved away from the tray can - * be resized from whichever side faces open screen. */ -const RESIZE_GRIPS: ReadonlyArray<{ edge: string; direction: ResizeDirection }> = [ - { edge: "top", direction: "North" }, - { edge: "bottom", direction: "South" }, - { edge: "left", direction: "West" }, - { edge: "right", direction: "East" }, - { edge: "topleft", direction: "NorthWest" }, - { edge: "topright", direction: "NorthEast" }, - { edge: "bottomleft", direction: "SouthWest" }, - { edge: "bottomright", direction: "SouthEast" }, -]; - -/** - * Window handles for the borderless flyout: a move strip along the top and - * invisible resize grips on every edge and corner. The native frame left - * around the borderless WebView2 is only a few pixels wide, so both are driven - * explicitly with `startDragging` / `startResizeDragging`. Those calls enter a - * Win32 modal move/size loop which transiently steals focus from the WebView2 - * child — Windows fires a spurious `Focused(false)` the instant the press - * starts. The backend keeps the flyout open on a blur while a mouse button is - * held on it, so no gesture guard is armed here; arming one would also keep - * the next genuine outside click from dismissing the panel for up to 15s. - * - * Dragging the strip moves the flyout away from the tray and the backend - * remembers the spot; double-clicking it anchors the flyout to the tray again. - */ -function TrayWindowHandles({ moveHint }: { moveHint: string }) { - return ( - <> -
{ - if (e.button !== 0) return; - e.preventDefault(); - if (e.detail === 2) { - void resetFlyoutPosition().catch((err) => - console.error("[tray-move] resetFlyoutPosition failed:", err), - ); - return; - } - void getCurrentWindow() - .startDragging() - .catch((err) => console.error("[tray-move] startDragging failed:", err)); - }} - > - -
- {RESIZE_GRIPS.map(({ edge, direction }) => ( -
{ - if (e.button !== 0) return; - e.preventDefault(); - void getCurrentWindow() - .startResizeDragging(direction) - .catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); - }} - /> - ))} - - ); -} - function OverviewSpendSummary({ providerIds, period, diff --git a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css index 25c444888b..275af2cd36 100644 --- a/apps/desktop-tauri/src/surfaces/menu-surface-layout.css +++ b/apps/desktop-tauri/src/surfaces/menu-surface-layout.css @@ -155,10 +155,11 @@ border-radius: 0; padding: 0; } -.menu-surface--tray .menu-stack__sep { +.menu-surface--tray .menu-stack__sep, +.menu-surface--tray .provider-grid__divider { height: 1px; - margin: 4px var(--surface-inset); - background: var(--panel-border); + margin: 5px 14px; + background: var(--mac-separator); } .menu-surface--tray .provider-grid { padding: 8px var(--surface-inset); @@ -167,15 +168,3 @@ .menu-surface--tray .provider-grid__item { min-height: 32px; } -.menu-surface--tray .menu-surface__footer-row { - min-height: 28px; - font-size: 12px; - padding-top: 5px; - padding-bottom: 5px; -} -.context-actions { - padding: 0 var(--surface-inset); -} -.context-actions__btn { - min-height: 28px; -} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx index 8c57e3a31a..8cff53314d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; vi.mock("../../../hooks/useLocale", () => ({ @@ -48,12 +48,40 @@ function renderTab( } describe("DisplayTab menu settings", () => { - it("no longer offers the retired PopOut window scale", () => { - // Window scale only zoomed the retired PopOut layout. The tray panel - // has its own Zoom slider in its footer (trayScalePercent). + it("offers the tray Panel scale as its only slider, with no PopOut window scale", () => { const { container } = renderTab(vi.fn()); - expect(container.querySelector('input[type="range"]')).toBeNull(); + expect( + Array.from(container.querySelectorAll('input[type="range"]')).map((input) => + input.getAttribute("aria-label"), + ), + ).toEqual(["PanelScaleLabel"]); + }); + + it("shows the saved Panel scale and saves the new one when the slider is released", async () => { + const set = vi.fn(); + await act(async () => { + render( + , + ); + }); + + const slider = screen.getByRole("slider", { name: "PanelScaleLabel" }); + expect(slider).toHaveValue("150"); + expect(screen.getByText("PanelScaleLabel (150%)")).toBeInTheDocument(); + + fireEvent.change(slider, { target: { value: "125" } }); + expect(screen.getByText("PanelScaleLabel (125%)")).toBeInTheDocument(); + expect(set).not.toHaveBeenCalled(); + fireEvent.pointerUp(slider); + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith({ trayScalePercent: 125 }); }); it("updates the exhausted reset display preference", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index 51f2607e42..79c6ed0aed 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useDraftNumber } from "../../../hooks/useDraftNumber"; import { useLocale } from "../../../hooks/useLocale"; import { Field, Select, Toggle } from "../../../components/FormControls"; import type { @@ -12,6 +13,12 @@ import type { TabProps } from "../settingsTabs"; import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; import SwitcherShortcutsSection from "../SwitcherShortcutsSection"; import { getTrayVisibilityStatus } from "../../../lib/tauri"; +import { + clampTrayScalePercent, + TRAY_SCALE_MAX, + TRAY_SCALE_MIN, + TRAY_SCALE_STEP, +} from "../../../lib/trayScale"; export default function DisplayTab({ mode = "menu", @@ -25,6 +32,10 @@ export default function DisplayTab({ }) { const { t } = useLocale(); const [trayVisibility, setTrayVisibility] = useState(null); + const panelScale = useDraftNumber(clampTrayScalePercent(settings.trayScalePercent)); + const commitPanelScale = () => { + panelScale.commit(panelScale.draft, (value) => set({ trayScalePercent: value })); + }; useEffect(() => { getTrayVisibilityStatus() @@ -259,6 +270,24 @@ export default function DisplayTab({ onChange={(v) => set({ showPace: v })} /> + + panelScale.setDraft(Number(e.target.value))} + onPointerUp={commitPanelScale} + onTouchEnd={commitPanelScale} + onBlur={commitPanelScale} + onKeyUp={commitPanelScale} + aria-label={t("PanelScaleLabel")} + /> +
} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c20b4ca68..294221c390 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ Cargo workspace (root `Cargo.toml`): members `rust`, `apps/desktop-tauri/src-tau ## Surfaces (desktop) -- **Tray panel** — left-click tray; blur-dismiss (suppressed in proof mode, and while a mouse button is held on the panel so a frame move/resize doesn't close it). Opens next to the tray until the user drags its top strip elsewhere; `shell/flyout_placement.rs` remembers that spot (double-click the strip to forget it). Resizable from every edge and corner. +- **Tray panel** — left-click tray; blur-dismiss (suppressed in proof mode and while a provider tab is dragged to reorder). A light, Mac-style panel 310 px wide (times the **Panel scale** setting in Settings > Menu) whose height fits its content. It always opens next to the tray and can't be moved or resized. - **Tray panel flyout** — larger dashboard window opened from the tray menu ("Pop Out Dashboard"); the tray panel itself is the only dashboard layout. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index eeafc73f0f..fd5ef31d07 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -445,7 +445,11 @@ locale_keys! { // Main popup - Bottom menu (Actions) MenuSettings, MenuAbout, + MenuAboutVersion, MenuQuit, + TrayMenuSwitchAccount, + TrayMenuUsageDashboard, + TrayMenuStatusPage, // Main popup - Status strings StatusJustUpdated, @@ -964,7 +968,8 @@ locale_keys! { DisplayModeMinimal, TrayPanelAlwaysOnTopLabel, TrayPanelAlwaysOnTopHelper, - TrayMoveHandleHint, + PanelScaleLabel, + PanelScaleHelper, WindowMinimize, WindowRestore, WindowClose, @@ -1062,7 +1067,6 @@ locale_keys! { PanelAllProvidersShort, PanelShowAllProviders, PanelShowFewerProviders, - PanelZoom, PanelMenu, PanelCopied, PanelToday, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 4d60d79956..94d30118c9 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -212,7 +212,11 @@ UpdateFailed = Update failed ButtonOpenProviderSettings = Open provider settings MenuSettings = Settings... MenuAbout = About CodexBar +MenuAboutVersion = About CodexBar (v{ "{}" }) MenuQuit = Quit +TrayMenuSwitchAccount = Switch Account... +TrayMenuUsageDashboard = Usage Dashboard +TrayMenuStatusPage = Status Page StatusJustUpdated = Just updated StatusUnableToGetUsage = Unable to get usage ActionRefresh = Refresh @@ -560,7 +564,8 @@ DisplayModeCompact = Compact DisplayModeMinimal = Minimal TrayPanelAlwaysOnTopLabel = Always on top TrayPanelAlwaysOnTopHelper = Keep the tray panel above other windows, even when it loses focus. -TrayMoveHandleHint = Drag to move. Double-click to put it back by the tray. +PanelScaleLabel = Panel scale +PanelScaleHelper = Scales the tray panel width, text, and spacing. WindowMinimize = Minimize WindowRestore = Restore WindowClose = Close @@ -650,7 +655,6 @@ PanelAllProviders = All providers PanelAllProvidersShort = All PanelShowAllProviders = Show all providers PanelShowFewerProviders = Show fewer providers -PanelZoom = Zoom PanelMenu = Menu PanelCopied = Copied PanelToday = Today diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index af7de2d65b..01da040375 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -185,7 +185,11 @@ UpdateFailed = Actualización fallida ButtonOpenProviderSettings = Abrir configuración del proveedor MenuSettings = Configuración... MenuAbout = Acerca de CodexBar +MenuAboutVersion = Acerca de CodexBar (v{ "{}" }) MenuQuit = Salir +TrayMenuSwitchAccount = Cambiar cuenta... +TrayMenuUsageDashboard = Panel de uso +TrayMenuStatusPage = Página de estado StatusJustUpdated = Recién actualizado StatusUnableToGetUsage = No se pudo obtener el uso ActionRefresh = Actualizar @@ -578,7 +582,8 @@ DisplayModeCompact = Compacto DisplayModeMinimal = Mínimo TrayPanelAlwaysOnTopLabel = Siempre visible encima TrayPanelAlwaysOnTopHelper = Mantén el panel de bandeja sobre otras ventanas, incluso cuando pierda el foco. -TrayMoveHandleHint = Arrastra para mover. Haz doble clic para devolverlo junto a la bandeja. +PanelScaleLabel = Escala del panel +PanelScaleHelper = Ajusta el ancho, el texto y el espaciado del panel de la bandeja. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Cerrar @@ -668,7 +673,6 @@ PanelAllProviders = Todos los proveedores PanelAllProvidersShort = Todos PanelShowAllProviders = Mostrar todos los proveedores PanelShowFewerProviders = Mostrar menos proveedores -PanelZoom = Zoom PanelMenu = Menú PanelCopied = Copiado PanelToday = Hoy diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 4eee267303..ad7f714bcb 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -185,7 +185,11 @@ UpdateFailed = 更新失敗 ButtonOpenProviderSettings = プロバイダー設定を開く MenuSettings = 設定... MenuAbout = CodexBar について +MenuAboutVersion = CodexBar について (v{ "{}" }) MenuQuit = 終了 +TrayMenuSwitchAccount = アカウントを切り替え… +TrayMenuUsageDashboard = 使用量ダッシュボード +TrayMenuStatusPage = ステータスページ StatusJustUpdated = 更新済み StatusUnableToGetUsage = 使用量を取得できません ActionRefresh = 更新 @@ -533,7 +537,8 @@ DisplayModeCompact = コンパクト DisplayModeMinimal = 最小 TrayPanelAlwaysOnTopLabel = 常に手前に表示 TrayPanelAlwaysOnTopHelper = フォーカスを失っても、トレイパネルを他のウィンドウより前面に表示します。 -TrayMoveHandleHint = ドラッグで移動。ダブルクリックでトレイの横に戻します。 +PanelScaleLabel = パネルの拡大率 +PanelScaleHelper = トレイパネルの幅、文字、余白を拡大します。 WindowMinimize = 最小化 WindowRestore = 元に戻す WindowClose = 閉じる @@ -623,7 +628,6 @@ PanelAllProviders = すべてのプロバイダー PanelAllProvidersShort = すべて PanelShowAllProviders = すべてのプロバイダーを表示 PanelShowFewerProviders = 表示を減らす -PanelZoom = ズーム PanelMenu = メニュー PanelCopied = コピーしました PanelToday = 今日 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 5dcb6cdd23..794ad768e4 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -185,7 +185,11 @@ UpdateFailed = 업데이트 실패 ButtonOpenProviderSettings = 제공업체 설정 열기 MenuSettings = 설정... MenuAbout = CodexBar 정보 +MenuAboutVersion = CodexBar 정보 (v{ "{}" }) MenuQuit = 종료 +TrayMenuSwitchAccount = 계정 전환... +TrayMenuUsageDashboard = 사용량 대시보드 +TrayMenuStatusPage = 상태 페이지 StatusJustUpdated = 방금 업데이트됨 StatusUnableToGetUsage = 사용량을 가져올 수 없음 ActionRefresh = 새로고침 @@ -578,7 +582,8 @@ DisplayModeCompact = 압축 DisplayModeMinimal = 최소 TrayPanelAlwaysOnTopLabel = 항상 위에 표시 TrayPanelAlwaysOnTopHelper = 포커스를 잃어도 트레이 패널을 다른 창보다 앞에 유지합니다. -TrayMoveHandleHint = 드래그하여 이동합니다. 두 번 클릭하면 트레이 옆으로 돌아갑니다. +PanelScaleLabel = 패널 배율 +PanelScaleHelper = 트레이 패널의 너비, 글자, 간격을 조정합니다. WindowMinimize = 최소화 WindowRestore = 이전 크기로 복원 WindowClose = 닫기 @@ -668,7 +673,6 @@ PanelAllProviders = 모든 제공자 PanelAllProvidersShort = 전체 PanelShowAllProviders = 모든 제공자 표시 PanelShowFewerProviders = 적은 제공자 표시 -PanelZoom = 확대/축소 PanelMenu = 메뉴 PanelCopied = 복사됨 PanelToday = 오늘 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index ef6f9509f1..b72df9bfba 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -208,7 +208,11 @@ UpdateFailed = Falha na atualização ButtonOpenProviderSettings = Abrir configurações do provedor MenuSettings = Configurações... MenuAbout = Sobre o CodexBar +MenuAboutVersion = Sobre o CodexBar (v{ "{}" }) MenuQuit = Sair +TrayMenuSwitchAccount = Trocar de conta... +TrayMenuUsageDashboard = Painel de uso +TrayMenuStatusPage = Página de status StatusJustUpdated = Atualizado agora StatusUnableToGetUsage = Não foi possível obter o uso ActionRefresh = Atualizar @@ -531,7 +535,8 @@ DisplayModeCompact = Compacto DisplayModeMinimal = Mínimo TrayPanelAlwaysOnTopLabel = Sempre no topo TrayPanelAlwaysOnTopHelper = Mantenha o painel da bandeja acima das outras janelas, mesmo quando ele perder o foco. -TrayMoveHandleHint = Arraste para mover. Clique duas vezes para voltar ao lado da bandeja. +PanelScaleLabel = Escala do painel +PanelScaleHelper = Ajusta a largura, o texto e o espaçamento do painel da bandeja. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Fechar @@ -621,7 +626,6 @@ PanelAllProviders = Todos os provedores PanelAllProvidersShort = Todos PanelShowAllProviders = Mostrar todos os provedores PanelShowFewerProviders = Mostrar menos provedores -PanelZoom = Zoom PanelMenu = Menu PanelCopied = Copiado PanelToday = Hoje diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 7a00337d21..1034d439a2 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -212,7 +212,11 @@ UpdateFailed = Обновление не выполнено ButtonOpenProviderSettings = Открыть настройки провайдера MenuSettings = Настройки... MenuAbout = О CodexBar +MenuAboutVersion = О CodexBar (v{ "{}" }) MenuQuit = Выйти +TrayMenuSwitchAccount = Сменить аккаунт... +TrayMenuUsageDashboard = Панель управления использованием +TrayMenuStatusPage = Страница статуса StatusJustUpdated = Только что обновлено StatusUnableToGetUsage = Не удалось получить использование ActionRefresh = Обновить @@ -560,7 +564,8 @@ DisplayModeCompact = Компактный DisplayModeMinimal = Минимальный TrayPanelAlwaysOnTopLabel = Поверх всех окон TrayPanelAlwaysOnTopHelper = Панель в области уведомлений остается поверх других окон даже после потери фокуса. -TrayMoveHandleHint = Перетащите, чтобы переместить. Двойной щелчок вернёт панель к области уведомлений. +PanelScaleLabel = Масштаб панели +PanelScaleHelper = Масштабирует ширину, текст и отступы панели в трее. WindowMinimize = Свернуть WindowRestore = Восстановить WindowClose = Закрыть @@ -650,7 +655,6 @@ PanelAllProviders = Все провайдеры PanelAllProvidersShort = Все PanelShowAllProviders = Показать всех провайдеров PanelShowFewerProviders = Показать меньше провайдеров -PanelZoom = Увеличить PanelMenu = Меню PanelCopied = Скопировано PanelToday = Сегодня diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 569c6c0b00..7f64280fa8 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -145,7 +145,6 @@ fn test_japanese_tray_panel_locale_values_are_translated() { (LocaleKey::StateError, "エラー"), (LocaleKey::PanelAllProviders, "すべてのプロバイダー"), (LocaleKey::PanelAllProvidersShort, "すべて"), - (LocaleKey::PanelZoom, "ズーム"), (LocaleKey::PanelMenu, "メニュー"), (LocaleKey::PanelToday, "今日"), (LocaleKey::PanelLatestTokens, "最新トークン"), @@ -181,7 +180,6 @@ fn test_chinese_tray_panel_locale_values_are_translated() { let cases = [ (LocaleKey::PanelAllProviders, "所有提供者"), (LocaleKey::PanelAllProvidersShort, "全部"), - (LocaleKey::PanelZoom, "缩放"), (LocaleKey::PanelMenu, "菜单"), (LocaleKey::PanelToday, "今日"), (LocaleKey::PanelLatestTokens, "最新令牌"), @@ -215,7 +213,6 @@ fn test_korean_tray_panel_locale_values_are_translated() { let cases = [ (LocaleKey::PanelAllProviders, "모든 제공자"), (LocaleKey::PanelAllProvidersShort, "전체"), - (LocaleKey::PanelZoom, "확대/축소"), (LocaleKey::PanelMenu, "메뉴"), (LocaleKey::PanelToday, "오늘"), (LocaleKey::PanelLatestTokens, "최신 토큰"), @@ -249,7 +246,6 @@ fn test_spanish_tray_panel_locale_values_are_translated() { let cases = [ (LocaleKey::PanelAllProviders, "Todos los proveedores"), (LocaleKey::PanelAllProvidersShort, "Todos"), - (LocaleKey::PanelZoom, "Zoom"), (LocaleKey::PanelMenu, "Menú"), (LocaleKey::PanelToday, "Hoy"), (LocaleKey::PanelLatestTokens, "Últimos tokens"), diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 41ec3e2c98..93fb051a15 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -212,7 +212,11 @@ UpdateFailed = Güncelleme başarısız ButtonOpenProviderSettings = Sağlayıcı ayarlarını aç MenuSettings = Ayarlar... MenuAbout = CodexBar hakkında +MenuAboutVersion = CodexBar hakkında (v{ "{}" }) MenuQuit = Çık +TrayMenuSwitchAccount = Hesap değiştir... +TrayMenuUsageDashboard = Kullanım panosu +TrayMenuStatusPage = Durum sayfası StatusJustUpdated = Az önce güncellendi StatusUnableToGetUsage = Kullanım bilgisi alınamıyor ActionRefresh = Yenile @@ -560,7 +564,8 @@ DisplayModeCompact = Kompakt DisplayModeMinimal = En az TrayPanelAlwaysOnTopLabel = Her zaman üstte TrayPanelAlwaysOnTopHelper = Odak kaybolduğunda bile tepsi panelini diğer pencerelerin üzerinde tutar. -TrayMoveHandleHint = Taşımak için sürükleyin. Tepsinin yanına geri döndürmek için çift tıklayın. +PanelScaleLabel = Panel ölçeği +PanelScaleHelper = Tepsi panelinin genişliğini, metnini ve boşluklarını ölçekler. WindowMinimize = Simge durumuna küçült WindowRestore = Geri yükle WindowClose = Kapat @@ -650,7 +655,6 @@ PanelAllProviders = Tüm sağlayıcılar PanelAllProvidersShort = Tümü PanelShowAllProviders = Tüm sağlayıcıları göster PanelShowFewerProviders = Daha az sağlayıcı göster -PanelZoom = Yakınlaştırma PanelMenu = Menü PanelCopied = Kopyalandı PanelToday = Bugün diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 525fd917c6..185a41b80c 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -212,7 +212,11 @@ UpdateFailed = Не вдалося оновити ButtonOpenProviderSettings = Відкрити налаштування провайдера MenuSettings = Налаштування... MenuAbout = Про CodexBar +MenuAboutVersion = Про CodexBar (v{ "{}" }) MenuQuit = Вийти +TrayMenuSwitchAccount = Перемкнути обліковий запис... +TrayMenuUsageDashboard = Панель використання +TrayMenuStatusPage = Сторінка стану StatusJustUpdated = Щойно оновлено StatusUnableToGetUsage = Не вдалося отримати дані про використання ActionRefresh = Оновити @@ -560,7 +564,8 @@ DisplayModeCompact = Компактний DisplayModeMinimal = Мінімальний TrayPanelAlwaysOnTopLabel = Поверх усіх вікон TrayPanelAlwaysOnTopHelper = Тримати панель трею поверх інших вікон, навіть коли вона втрачає фокус. -TrayMoveHandleHint = Перетягніть, щоб перемістити. Двічі клацніть, щоб повернути панель до трею. +PanelScaleLabel = Масштаб панелі +PanelScaleHelper = Масштабує ширину, текст і відступи панелі в треї. WindowMinimize = Згорнути WindowRestore = Відновити WindowClose = Закрити @@ -650,7 +655,6 @@ PanelAllProviders = Усі провайдери PanelAllProvidersShort = Усі PanelShowAllProviders = Показати всіх провайдерів PanelShowFewerProviders = Показати менше провайдерів -PanelZoom = Масштаб PanelMenu = Меню PanelCopied = Скопійовано PanelToday = Сьогодні diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index f5e05665fc..176946e679 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -185,7 +185,11 @@ UpdateFailed = 更新失败 ButtonOpenProviderSettings = 打开服务商设置 MenuSettings = 设置... MenuAbout = 关于 CodexBar +MenuAboutVersion = 关于 CodexBar (v{ "{}" }) MenuQuit = 退出 +TrayMenuSwitchAccount = 切换账号... +TrayMenuUsageDashboard = 用量仪表盘 +TrayMenuStatusPage = 状态页面 StatusJustUpdated = 刚刚更新 StatusUnableToGetUsage = 无法获取用量 ActionRefresh = 刷新 @@ -533,7 +537,8 @@ DisplayModeCompact = 紧凑 DisplayModeMinimal = 最简 TrayPanelAlwaysOnTopLabel = 始终置顶 TrayPanelAlwaysOnTopHelper = 即使失去焦点,也让托盘面板保持在其他窗口之上。 -TrayMoveHandleHint = 拖动以移动。双击可放回托盘旁。 +PanelScaleLabel = 面板缩放 +PanelScaleHelper = 缩放托盘面板的宽度、文字和间距。 WindowMinimize = 最小化 WindowRestore = 还原 WindowClose = 关闭 @@ -623,7 +628,6 @@ PanelAllProviders = 所有提供者 PanelAllProvidersShort = 全部 PanelShowAllProviders = 显示所有提供者 PanelShowFewerProviders = 显示较少提供者 -PanelZoom = 缩放 PanelMenu = 菜单 PanelCopied = 已复制 PanelToday = 今日 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index e9faab8be6..b10ee45da6 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -185,7 +185,11 @@ UpdateFailed = 更新失敗 ButtonOpenProviderSettings = 開啟提供者設定 MenuSettings = 設定... MenuAbout = 關於 CodexBar +MenuAboutVersion = 關於 CodexBar (v{ "{}" }) MenuQuit = 退出 +TrayMenuSwitchAccount = 切換賬號... +TrayMenuUsageDashboard = 用量儀表板 +TrayMenuStatusPage = 狀態頁面 StatusJustUpdated = 剛剛更新 StatusUnableToGetUsage = 無法獲取用量 ActionRefresh = 重新整理 @@ -533,7 +537,8 @@ DisplayModeCompact = 緊湊 DisplayModeMinimal = 最簡 TrayPanelAlwaysOnTopLabel = 永遠置頂 TrayPanelAlwaysOnTopHelper = 即使失去焦點,也讓系統匣面板保持在其他視窗上方。 -TrayMoveHandleHint = 拖曳以移動。按兩下可放回系統匣旁。 +PanelScaleLabel = 面板縮放 +PanelScaleHelper = 縮放系統匣面板的寬度、文字和間距。 WindowMinimize = 最小化 WindowRestore = 還原 WindowClose = 關閉 @@ -623,7 +628,6 @@ PanelAllProviders = 所有提供者 PanelAllProvidersShort = 全部 PanelShowAllProviders = 顯示所有提供者 PanelShowFewerProviders = 顯示較少提供者 -PanelZoom = 縮放 PanelMenu = 選單 PanelCopied = 已複製 PanelToday = 今日