From ee903aed82018b256e898bf5a15a49c6082df01d Mon Sep 17 00:00:00 2001 From: yozu Date: Tue, 6 Oct 2026 12:39:12 +0900 Subject: [PATCH 1/2] Let the tray panel be moved and resized again Since 0.70.0 the tray-panel flyout is the only dashboard and it could not be moved. Resizing from its outer frame (Tauri's built-in undecorated resize border) blurred the WebView before the native size loop started, so the blur-dismiss handler hid the panel; only the hidden 10px top/left grips worked. - Add a move strip along the top (startDragging). The spot the user drags the panel to is recorded when the Win32 move/size loop ends (WM_EXITSIZEMOVE, with WM_SIZING telling resizes from moves) and every later open or content-driven resize keeps it there, clamped into the monitor's work area. Double-clicking the strip anchors the panel to the tray again. - Add resize grips on every edge and corner. - Keep the panel open on blur while a mouse button is held with the cursor inside it, which covers native frame moves/resizes. - Re-clamp a placed panel after a DPI change rescales it. - Store the remembered panel size in logical px (new key) so it fits monitors with different scaling; the old physical-px size is ignored. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../src-tauri/src/commands/surface.rs | 15 +- .../src-tauri/src/geometry_store.rs | 22 +- apps/desktop-tauri/src-tauri/src/main.rs | 1 + .../src-tauri/src/shell/activation.rs | 2 +- .../src-tauri/src/shell/flyout_placement.rs | 390 ++++++++++++++++++ .../src-tauri/src/shell/flyout_window.rs | 77 +++- apps/desktop-tauri/src-tauri/src/shell/mod.rs | 1 + .../src/hooks/useTrayPanelController.ts | 16 +- .../src/hooks/useTrayPanelLayout.ts | 9 +- apps/desktop-tauri/src/i18n/keys.ts | 1 + apps/desktop-tauri/src/lib/tauri.ts | 5 + apps/desktop-tauri/src/styles.css | 88 +++- .../src/surfaces/TrayPanel.test.tsx | 80 +++- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 98 +++-- docs/ARCHITECTURE.md | 2 +- rust/src/locale.rs | 1 + rust/src/locale/en-US.ftl | 1 + rust/src/locale/es-MX.ftl | 1 + rust/src/locale/ja-JP.ftl | 1 + rust/src/locale/ko-KR.ftl | 1 + rust/src/locale/pt-BR.ftl | 1 + rust/src/locale/ru-RU.ftl | 1 + rust/src/locale/tr-TR.ftl | 1 + rust/src/locale/zh-CN.ftl | 1 + rust/src/locale/zh-TW.ftl | 1 + 26 files changed, 738 insertions(+), 80 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ac715fe63..1ba152ddd38 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Show independent session and weekly usage for native saved Claude Code accounts, with account-scoped OAuth renewal and safe authentication warnings. - Offer Refresh login beside Switch only for Claude and Codex accounts that need sign-in. Repair the selected saved login without switching to another account. - Use expanded, collapsible Claude and Codex account sections with Add account below the rows; keep the floating bar's usage footprint when authentication expires. +- Let the tray panel be moved and resized again: drag the strip along its top to move it (the spot is remembered; double-click returns it next to the tray), resize from any edge or corner, and stop it from closing when a resize starts on its outer frame. Its remembered size is now kept in logical pixels, so it fits monitors with different scaling (a size saved by an earlier version is reset to the automatic size once). ## [Windows] 0.70.0 - 2026-10-03 diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index c0ec61bdff3..00b268abdfd 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -116,10 +116,10 @@ pub fn close_settings_window( crate::shell::settings_window::dismiss(&app, &window) } -/// Persist a user-chosen size for the "Pop Out Dashboard" flyout window. -/// Only the size is stored (via a size-only `StoredSize` entry — no -/// fabricated `x`/`y`); the flyout is always re-anchored above the tray on -/// open. The frontend calls this on genuine user drag-resizes, not on its own +/// 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> { @@ -129,6 +129,13 @@ pub fn set_flyout_size(width: f64, height: f64) -> Result<(), String> { 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 diff --git a/apps/desktop-tauri/src-tauri/src/geometry_store.rs b/apps/desktop-tauri/src-tauri/src/geometry_store.rs index 1a60aea9865..c5945c2801a 100644 --- a/apps/desktop-tauri/src-tauri/src/geometry_store.rs +++ b/apps/desktop-tauri/src-tauri/src/geometry_store.rs @@ -3,11 +3,11 @@ //! Remembers position (and size where applicable) for detached user surfaces: //! PopOut and Settings. //! -//! The flyout window stays computed from the tray anchor/work-area because it -//! is a temporary anchored panel, not a user-movable standalone window — but -//! its SIZE is remembered via the size-only [`StoredSize`] entries below, kept -//! separate from [`StoredGeometry`] so the flyout's persisted size can never -//! carry fabricated `x`/`y` coordinates. +//! 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. use std::fs; use std::path::PathBuf; @@ -154,6 +154,18 @@ 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"; diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 6160de4bb25..d3f9cbd4d99 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -214,6 +214,7 @@ fn main() { 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, diff --git a/apps/desktop-tauri/src-tauri/src/shell/activation.rs b/apps/desktop-tauri/src-tauri/src/shell/activation.rs index a3b49b28126..f382d69d2b5 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/activation.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/activation.rs @@ -194,7 +194,7 @@ pub fn restore_minimized_without_activation(window: &impl raw_window_handle::Has pub fn restore_minimized_without_activation(_window: &impl raw_window_handle::HasWindowHandle) {} #[cfg(windows)] -fn root_hwnd(window: &impl raw_window_handle::HasWindowHandle) -> Option { +pub(super) fn root_hwnd(window: &impl raw_window_handle::HasWindowHandle) -> Option { const GA_ROOT: u32 = 2; let handle = window.window_handle().ok()?; let raw_window_handle::RawWindowHandle::Win32(h) = handle.as_raw() else { diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs new file mode 100644 index 00000000000..9bd992695f0 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs @@ -0,0 +1,390 @@ +//! 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 tauri::Manager; + +use crate::geometry_store::{self, StoredGeometry}; +use crate::window_positioner::{self, PanelSize}; + +/// 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 of the monitor that +/// holds it (the primary monitor when that one is gone), so a monitor layout +/// change can't leave the panel 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 monitor = window + .monitor_from_point(f64::from(x), f64::from(y)) + .ok() + .flatten() + .or_else(|| window.primary_monitor().ok().flatten())?; + let outer = window.outer_size().ok()?; + // The size is already physical, so clamp with a 1.0 scale. + Some(window_positioner::clamp_position_to_work_area( + x, + y, + &super::geometry::monitor_work_area_rect(&monitor), + &PanelSize { + width: outer.width, + height: outer.height, + }, + 1.0, + )) +} + +/// Whether a mouse button is held with the cursor over the flyout. +/// +/// 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. +pub fn pointer_pressed_inside(window: &tauri::Window) -> bool { + if !mouse_button_down() { + return false; + } + let (Ok(cursor), Ok(origin), Ok(size)) = ( + window.app_handle().cursor_position(), + window.outer_position(), + window.outer_size(), + ) else { + return false; + }; + point_in_window( + (cursor.x, cursor.y), + (origin.x, origin.y), + (size.width, size.height), + ) +} + +fn point_in_window(point: (f64, f64), origin: (i32, i32), size: (u32, u32)) -> bool { + let (x, y) = point; + let left = f64::from(origin.0); + let top = f64::from(origin.1); + x >= left && x < left + f64::from(size.0) && y >= top && y < top + f64::from(size.1) +} + +/// 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; + +/// 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_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(not(windows))] +fn mouse_button_down() -> bool { + false +} + +#[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; +} + +#[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; +} + +#[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); + } + + #[test] + fn a_press_on_the_frame_counts_as_inside() { + let origin = (100, 200); + let size = (300, 800); + assert!(point_in_window((100.0, 200.0), origin, size)); + assert!(point_in_window((399.0, 999.0), origin, size)); + } + + #[test] + fn a_press_outside_the_window_is_not_inside() { + let origin = (100, 200); + let size = (300, 800); + assert!(!point_in_window((400.0, 500.0), origin, size)); + assert!(!point_in_window((99.0, 500.0), origin, size)); + assert!(!point_in_window((200.0, 1000.0), origin, size)); + assert!(!point_in_window((200.0, 199.0), origin, size)); + } +} 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 7f3a2bec4e2..6a77aa808b9 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -5,7 +5,8 @@ //! `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. +//! 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` @@ -27,13 +28,18 @@ use crate::surface::SurfaceMode; pub const FLYOUT_LABEL: &str = "flyout"; -/// Geometry-store key for the flyout's remembered SIZE (position is never -/// stored — each open chooses a tray or captured cursor anchor). 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. -const FLYOUT_SIZE_KEY: &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 auto-fit size once. +const FLYOUT_SIZE_KEY: &str = "flyoutLogical"; /// 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`, @@ -60,7 +66,8 @@ 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. Size-only — no fabricated position. +/// 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 }); } @@ -172,8 +179,10 @@ fn open_with_anchor( // 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); - if cursor.is_some() { + let user_placed = position.is_none() && super::flyout_placement::stored_position().is_some(); + if cursor.is_some() || user_placed { reanchor(app)?; } else if let Some((x, y)) = position.or_else(|| super::position::default_surface_position(app, SurfaceMode::TrayPanel)) @@ -315,6 +324,11 @@ 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; }; @@ -347,8 +361,24 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - // 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. - // Position is never persisted; resizing follows this open's selected anchor. + // User positions are recorded by `flyout_placement` when the native + // move/size loop ends, not per event. tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => true, + tauri::WindowEvent::ScaleFactorChanged { .. } => { + // Landing on a monitor with another DPI rescales the window after + // it was clamped at the old size. Clamp again once the new size is + // applied, unless the user is still dragging it. + if super::flyout_placement::stored_position().is_some() + && !super::flyout_placement::user_move_in_progress() + { + let app = app.clone(); + // Queued behind the rescale; a failure only skips the clamp. + let _reclamp = window.run_on_main_thread(move || { + let _reanchor = 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 @@ -362,7 +392,14 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } } -/// Reposition the flyout at its captured cursor or system-tray anchor, +/// 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 @@ -373,6 +410,15 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { let window = app .get_webview_window(FLYOUT_LABEL) .ok_or_else(|| "flyout window unavailable".to_string())?; + + // 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())?; @@ -472,6 +518,13 @@ mod tests { 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"); + } + #[test] fn tray_panel_window_properties_still_the_single_source_for_flyout_shape() { // `open_or_focus`'s builder reads size/decorations/resizable/ diff --git a/apps/desktop-tauri/src-tauri/src/shell/mod.rs b/apps/desktop-tauri/src-tauri/src/shell/mod.rs index 3741d5446d4..e7dd22ed16c 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/mod.rs @@ -8,6 +8,7 @@ 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/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 8e4784997d2..9effb2b1150 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -233,8 +233,20 @@ export function useTrayPanelController(state: BootstrapState) { window.clearTimeout(saveSizeTimerRef.current); } saveSizeTimerRef.current = window.setTimeout(() => { - setFlyoutSizeState([width, height]); - void setFlyoutSize(width, height).catch(() => {}); + // 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); + return setFlyoutSize(logical[0], logical[1]); + }) + .catch(() => {}); }, 300); }, []); useEffect( diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index 31ed7ea29bb..0b6f6bb71aa 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -41,8 +41,8 @@ export interface TrayPanelLayoutOptions { layoutKey: string; /** Auto-fit the window to its content (until the user sets a size). */ autoFit?: boolean; - /** The user's remembered size, re-applied + re-anchored each time the flyout - * opens. `null` when the user has not resized yet. */ + /** 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. */ @@ -159,8 +159,9 @@ export function useTrayPanelLayout({ if (!fixed) return; let cancelled = false; void (async () => { - // `fixed` is the user's remembered PHYSICAL size (scale-independent). - await applySize(new PhysicalSize(fixed[0], fixed[1])); + // `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; diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 475bbd52037..db5f16d74c9 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -627,6 +627,7 @@ export const ALL_LOCALE_KEYS = [ "DisplayModeMinimal", "TrayPanelAlwaysOnTopLabel", "TrayPanelAlwaysOnTopHelper", + "TrayMoveHandleHint", "WindowMinimize", "WindowRestore", "WindowClose", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index b203abd5c36..d1f8b7131ac 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -555,6 +555,11 @@ 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"); diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 6ae8ec2bee4..88e6514b427 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1123,33 +1123,99 @@ body:has(.tray-panel-reveal) { } } -/* Flyout resize grips (top / left / top-left corner). Invisible strips at the - window's in-screen edges; the cursor change signals they're draggable. */ +/* 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 { - top: 0; +.tray-resize--top, +.tray-resize--bottom { left: 0; right: 0; - height: 10px; + height: 6px; cursor: ns-resize; } -.tray-resize--left { +.tray-resize--top { top: 0; - left: 0; +} +.tray-resize--bottom { bottom: 0; - width: 10px; +} +.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; - width: 20px; - height: 20px; cursor: nwse-resize; - z-index: 51; +} +.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; } .shell--tray-panel { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 7126999b3e7..d4f90b6ac10 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -25,6 +25,7 @@ const tauriMocks = vi.hoisted(() => ({ revealTrayPanelWindow: vi.fn(), flyoutStoredSize: vi.fn().mockResolvedValue(null), setFlyoutSize: vi.fn().mockResolvedValue(undefined), + resetFlyoutPosition: vi.fn().mockResolvedValue(undefined), openProviderDashboard: vi.fn(), openProviderStatusPage: vi.fn(), getProviderChartData: vi.fn(), @@ -42,7 +43,12 @@ const eventMocks = vi.hoisted(() => ({ })); const windowMocks = vi.hoisted(() => ({ - getCurrentWindow: vi.fn(() => ({ + 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), @@ -237,6 +243,10 @@ describe("TrayPanel provider grid", () => { 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.refreshProviders.mockResolvedValue(undefined); tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); tauriMocks.dismissTrayPanel.mockResolvedValue(undefined); @@ -867,6 +877,74 @@ describe("TrayPanel provider grid", () => { expect(tauriMocks.setSurfaceMode).not.toHaveBeenCalled(); }); + it("offers a move strip and resize grips on every edge and corner", async () => { + const { container } = renderTrayPanel([provider("codex", "Codex", 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", + ]); + }); + + it("arms the blur guard and starts a native resize toward the grabbed corner", async () => { + const { container } = renderTrayPanel([provider("codex", "Codex", 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"); + }); + expect(tauriMocks.beginFlyoutGesture).toHaveBeenCalled(); + }); + + it("moves the flyout from the strip and puts it back by the tray on double-click", async () => { + const { container } = renderTrayPanel([provider("codex", "Codex", 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).toHaveBeenCalled(); + + fireEvent.mouseDown(strip, { button: 0, detail: 2 }); + await waitFor(() => { + expect(tauriMocks.resetFlyoutPosition).toHaveBeenCalledTimes(1); + }); + expect(windowMocks.startDragging).toHaveBeenCalledTimes(1); + }); + + it("ignores non-primary presses on the move strip", async () => { + const { container } = renderTrayPanel([provider("codex", "Codex", 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(); + }); + it("renders the tray footer zoom slider above Refresh and persists trayScalePercent after the debounce", async () => { const { container } = renderTrayPanel( [provider("claude", "Claude", 35)], diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 5f2f41e9af0..c8354484a67 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -1,5 +1,5 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +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"; @@ -11,6 +11,7 @@ import { openProviderDashboard, openProviderStatusPage, openSettingsWindow, + resetFlyoutPosition, } from "../lib/tauri"; import { TRAY_SCALE_MAX, @@ -176,7 +177,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { onSettings={openSettings} /> - + ); } @@ -280,61 +281,80 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { )} - + ); } +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" }, +]; + /** - * Invisible resize grips along the flyout's in-screen edges (top / left / - * top-left corner). The flyout is anchored bottom-right above the tray, so these - * let the user widen (left edge) or heighten (top edge) it. Native edge-resize - * doesn't work through the borderless WebView2, so we drive it explicitly with - * `startResizeDragging`. That call enters a Win32 modal size loop which - * transiently steals focus from the WebView2 child for its duration — Windows - * fires a spurious `Focused(false)` the instant the press starts even though - * the user never left the window. We arm a gesture-scoped blur guard on the - * backend *before* starting the loop so that transient blur doesn't + * Window handles for the borderless flyout: a move strip along the top and + * invisible resize grips on every edge and corner. Native edge-resize doesn't + * reach through the borderless WebView2, 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 even + * though the user never left the window. We arm a gesture-scoped blur guard on + * the backend *before* starting the loop so that transient blur doesn't * auto-hide the flyout; the guard clears itself once focus genuinely returns * (via the `Focused(true)` refocus path) or after a 15s expiry, so no * explicit end call is needed here — the OS loop swallows mouseup. + * + * 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 TrayResizeHandles() { +function TrayWindowHandles({ moveHint }: { moveHint: string }) { return ( <>
{ - e.preventDefault(); - void (async () => { - await beginFlyoutGesture().catch(() => {}); - await getCurrentWindow().startResizeDragging("North"); - })().catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); - }} - /> -
{ + if (e.button !== 0) return; e.preventDefault(); + if (e.detail === 2) { + void resetFlyoutPosition().catch((err) => + console.error("[tray-move] resetFlyoutPosition failed:", err), + ); + return; + } void (async () => { await beginFlyoutGesture().catch(() => {}); - await getCurrentWindow().startResizeDragging("West"); - })().catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); + await getCurrentWindow().startDragging(); + })().catch((err) => console.error("[tray-move] startDragging failed:", err)); }} - /> -
{ - e.preventDefault(); - void (async () => { - await beginFlyoutGesture().catch(() => {}); - await getCurrentWindow().startResizeDragging("NorthWest"); - })().catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); - }} - /> + > + +
+ {RESIZE_GRIPS.map(({ edge, direction }) => ( +
{ + e.preventDefault(); + void (async () => { + await beginFlyoutGesture().catch(() => {}); + await getCurrentWindow().startResizeDragging(direction); + })().catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); + }} + /> + ))} ); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 30d03b291c0..2c20b4ca68a 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). +- **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 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 4ac0f079d73..ade394c146a 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -895,6 +895,7 @@ locale_keys! { DisplayModeMinimal, TrayPanelAlwaysOnTopLabel, TrayPanelAlwaysOnTopHelper, + TrayMoveHandleHint, WindowMinimize, WindowRestore, WindowClose, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 93dfac2182c..e2035cbcead 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -545,6 +545,7 @@ 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. WindowMinimize = Minimize WindowRestore = Restore WindowClose = Close diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index ab108b8daab..a083d772f80 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -478,6 +478,7 @@ 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. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Cerrar diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 216698c2db1..b7dd11003d4 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -460,6 +460,7 @@ DisplayModeCompact = コンパクト DisplayModeMinimal = 最小 TrayPanelAlwaysOnTopLabel = 常に手前に表示 TrayPanelAlwaysOnTopHelper = フォーカスを失っても、トレイパネルを他のウィンドウより前面に表示します。 +TrayMoveHandleHint = ドラッグで移動。ダブルクリックでトレイの横に戻します。 WindowMinimize = 最小化 WindowRestore = 元に戻す WindowClose = 閉じる diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index ae1a432f660..e6c7c5dfb78 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -465,6 +465,7 @@ DisplayModeCompact = 압축 DisplayModeMinimal = 최소 TrayPanelAlwaysOnTopLabel = 항상 위에 표시 TrayPanelAlwaysOnTopHelper = 포커스를 잃어도 트레이 패널을 다른 창보다 앞에 유지합니다. +TrayMoveHandleHint = 드래그하여 이동합니다. 두 번 클릭하면 트레이 옆으로 돌아갑니다. WindowMinimize = 최소화 WindowRestore = 이전 크기로 복원 WindowClose = 닫기 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 0ff7e819df9..96b216906c5 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -514,6 +514,7 @@ 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. WindowMinimize = Minimizar WindowRestore = Restaurar WindowClose = Fechar diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index e7c596e4fe9..5c5e08f67f0 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -448,6 +448,7 @@ DisplayModeCompact = Компактный DisplayModeMinimal = Минимальный TrayPanelAlwaysOnTopLabel = Поверх всех окон TrayPanelAlwaysOnTopHelper = Панель в области уведомлений остается поверх других окон даже после потери фокуса. +TrayMoveHandleHint = Перетащите, чтобы переместить. Двойной щелчок вернёт панель к области уведомлений. WindowMinimize = Свернуть WindowRestore = Восстановить WindowClose = Закрыть diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index f911b37cb36..91ab3cf39b8 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -473,6 +473,7 @@ 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. WindowMinimize = Simge durumuna küçült WindowRestore = Geri yükle WindowClose = Kapat diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 0181f58910c..904bf2d7e13 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -447,6 +447,7 @@ DisplayModeCompact = 紧凑 DisplayModeMinimal = 最简 TrayPanelAlwaysOnTopLabel = 始终置顶 TrayPanelAlwaysOnTopHelper = 即使失去焦点,也让托盘面板保持在其他窗口之上。 +TrayMoveHandleHint = 拖动以移动。双击可放回托盘旁。 WindowMinimize = 最小化 WindowRestore = 还原 WindowClose = 关闭 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 078adee947c..b15c1c45994 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -447,6 +447,7 @@ DisplayModeCompact = 緊湊 DisplayModeMinimal = 最簡 TrayPanelAlwaysOnTopLabel = 永遠置頂 TrayPanelAlwaysOnTopHelper = 即使失去焦點,也讓系統匣面板保持在其他視窗上方。 +TrayMoveHandleHint = 拖曳以移動。按兩下可放回系統匣旁。 WindowMinimize = 最小化 WindowRestore = 還原 WindowClose = 關閉 From f1b99bed96d08f830ab9bf21ef0894046444014e Mon Sep 17 00:00:00 2001 From: yozu Date: Tue, 6 Oct 2026 13:08:59 +0900 Subject: [PATCH 2/2] Address review findings for the movable tray panel - Treat a DPI rescale as programmatic so moving an auto-fit panel onto a monitor with other scaling doesn't freeze it at a fixed size. - Build the flyout without a maximize box so dragging it to a screen edge can't snap or maximize it. - Pick the monitor for a remembered spot by the panel's overlap with each work area (nearest one when it overlaps none) instead of its corner. - Re-clamp a placed panel on the Resized that follows a DPI change, when the new size is in place, and never reposition it during a user drag. - Check the window under the cursor rather than the panel's bounds, so a click on another topmost window covering the panel still dismisses it. - Stop arming the 15s gesture blur guard from the move strip and grips; the held-button check covers them and the guard swallowed the next real outside click. - Re-anchor after every settled user resize, react to primary presses only on the grips, and remove the subclass on WM_NCDESTROY. Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/shell/flyout_placement.rs | 178 +++++++++++++----- .../src-tauri/src/shell/flyout_window.rs | 39 ++-- .../src/hooks/useTrayPanelController.ts | 4 + .../src/hooks/useTrayPanelLayout.ts | 22 ++- .../src/surfaces/TrayPanel.test.tsx | 96 +++++++++- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 34 ++-- 6 files changed, 289 insertions(+), 84 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs index 9bd992695f0..30df73a6666 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_placement.rs @@ -10,10 +10,8 @@ //! or its frame, never for `SetWindowPos`, so the flyout's own re-anchoring //! can't be mistaken for a user move. -use tauri::Manager; - use crate::geometry_store::{self, StoredGeometry}; -use crate::window_positioner::{self, PanelSize}; +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. @@ -41,23 +39,31 @@ pub fn clear_position() { geometry_store::remove_entry(FLYOUT_POSITION_KEY); } -/// The remembered position, clamped into the work area of the monitor that -/// holds it (the primary monitor when that one is gone), so a monitor layout -/// change can't leave the panel off-screen. `None` when the user never moved -/// the flyout. +/// 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 monitor = window - .monitor_from_point(f64::from(x), f64::from(y)) - .ok() - .flatten() - .or_else(|| window.primary_monitor().ok().flatten())?; 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, - &super::geometry::monitor_work_area_rect(&monitor), + &work_area, &PanelSize { width: outer.width, height: outer.height, @@ -66,35 +72,70 @@ pub fn placed_position(window: &tauri::WebviewWindow) -> Option<(i32, i32)> { )) } -/// Whether a mouse button is held with the cursor over the flyout. +/// 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. +/// 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 (Ok(cursor), Ok(origin), Ok(size)) = ( - window.app_handle().cursor_position(), - window.outer_position(), - window.outer_size(), - ) else { + let Some(flyout) = super::activation::root_hwnd(window) else { return false; }; - point_in_window( - (cursor.x, cursor.y), - (origin.x, origin.y), - (size.width, size.height), - ) + 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 + } } -fn point_in_window(point: (f64, f64), origin: (i32, i32), size: (u32, u32)) -> bool { - let (x, y) = point; - let left = f64::from(origin.0); - let top = f64::from(origin.1); - x >= left && x < left + f64::from(size.0) && y >= top && y < top + f64::from(size.1) +#[cfg(not(windows))] +pub fn pointer_pressed_inside(_window: &tauri::Window) -> bool { + false } /// Outer window bounds in physical px, as `GetWindowRect` reports them. @@ -175,6 +216,8 @@ const WM_ENTERSIZEMOVE: u32 = 0x0231; 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. @@ -206,6 +249,16 @@ unsafe extern "system" fn size_move_subclass_proc( 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() @@ -253,9 +306,12 @@ fn mouse_button_down() -> bool { .any(|key| unsafe { GetAsyncKeyState(key) } < 0) } -#[cfg(not(windows))] -fn mouse_button_down() -> bool { - false +#[cfg(windows)] +#[repr(C)] +#[derive(Default)] +struct Win32Point { + x: i32, + y: i32, } #[cfg(windows)] @@ -275,6 +331,9 @@ struct Win32Rect { 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)] @@ -289,6 +348,11 @@ unsafe extern "system" { 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)] @@ -370,21 +434,45 @@ mod tests { 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_press_on_the_frame_counts_as_inside() { - let origin = (100, 200); - let size = (300, 800); - assert!(point_in_window((100.0, 200.0), origin, size)); - assert!(point_in_window((399.0, 999.0), origin, size)); + 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 a_press_outside_the_window_is_not_inside() { - let origin = (100, 200); - let size = (300, 800); - assert!(!point_in_window((400.0, 500.0), origin, size)); - assert!(!point_in_window((99.0, 500.0), origin, size)); - assert!(!point_in_window((200.0, 1000.0), origin, size)); - assert!(!point_in_window((200.0, 199.0), origin, size)); + 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 6a77aa808b9..1b177f799a2 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -38,7 +38,7 @@ pub const FLYOUT_LABEL: &str = "flyout"; /// /// 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 auto-fit size once. +/// gets the legacy `"trayPanel"` size, or else the auto-fit size, once. const FLYOUT_SIZE_KEY: &str = "flyoutLogical"; /// Same window used to close a same-click blur-dismiss/reopen race as the @@ -55,6 +55,10 @@ 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); @@ -156,6 +160,9 @@ fn open_with_anchor( .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) .theme(Some(tauri::Theme::Dark)) @@ -363,19 +370,23 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - // 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(_) | tauri::WindowEvent::Resized(_) => true, + tauri::WindowEvent::Moved(_) => true, tauri::WindowEvent::ScaleFactorChanged { .. } => { - // Landing on a monitor with another DPI rescales the window after - // it was clamped at the old size. Clamp again once the new size is - // applied, unless the user is still dragging it. - if super::flyout_placement::stored_position().is_some() + // 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() { - let app = app.clone(); - // Queued behind the rescale; a failure only skips the clamp. - let _reclamp = window.run_on_main_thread(move || { - let _reanchor = reanchor(&app); - }); + // Best-effort; a failure leaves the panel where Windows put it. + let _reclamp = reanchor(app); } true } @@ -411,6 +422,12 @@ 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) { diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 9effb2b1150..9ae83df78a7 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -8,6 +8,7 @@ import { flyoutStoredSize, openSettingsWindow, quitApp as quitApplication, + reanchorTrayPanel, reorderProviders, setFlyoutSize, updateSettings, @@ -244,6 +245,9 @@ export function useTrayPanelController(state: BootstrapState) { 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(() => {}); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index 0b6f6bb71aa..e2543d89218 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -119,12 +119,26 @@ export function useTrayPanelLayout({ // 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. + // 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 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; @@ -141,11 +155,15 @@ export function useTrayPanelLayout({ } catch { unlisten = undefined; } - if (cancelled) unlisten?.(); + if (cancelled) { + unlisten?.(); + unlistenScale?.(); + } })(); return () => { cancelled = true; unlisten?.(); + unlistenScale?.(); }; }, []); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index d4f90b6ac10..7a3eac64ab0 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -878,7 +878,7 @@ describe("TrayPanel provider grid", () => { }); it("offers a move strip and resize grips on every edge and corner", async () => { - const { container } = renderTrayPanel([provider("codex", "Codex", 20)]); + const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); await waitFor(() => { expect(container.querySelector(".tray-move-handle")).not.toBeNull(); @@ -897,10 +897,13 @@ describe("TrayPanel provider grid", () => { "tray-resize--topleft", "tray-resize--topright", ]); + // Unmount while the mocks still resolve; the shared afterEach resets + // them before the automatic cleanup runs. + unmount(); }); - it("arms the blur guard and starts a native resize toward the grabbed corner", async () => { - const { container } = renderTrayPanel([provider("codex", "Codex", 20)]); + 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(); }); @@ -910,11 +913,16 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(windowMocks.startResizeDragging).toHaveBeenCalledWith("SouthEast"); }); - expect(tauriMocks.beginFlyoutGesture).toHaveBeenCalled(); + // 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 } = renderTrayPanel([provider("codex", "Codex", 20)]); + const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); await waitFor(() => { expect(container.querySelector(".tray-move-handle")).not.toBeNull(); }); @@ -924,17 +932,88 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(windowMocks.startDragging).toHaveBeenCalledTimes(1); }); - expect(tauriMocks.beginFlyoutGesture).toHaveBeenCalled(); + 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 } }); + }); + + await waitFor(() => { + expect(tauriMocks.setFlyoutSize).toHaveBeenCalledWith(450, 750); + }); + expect(tauriMocks.reanchorTrayPanel).toHaveBeenCalled(); + // Unmount while the mocks still resolve; the shared afterEach resets + // them before the automatic cleanup runs. + unmount(); + }); + + 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)); + + 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 } = renderTrayPanel([provider("codex", "Codex", 20)]); + const { container, unmount } = renderTrayPanel([provider("cursor", "Cursor", 20)]); await waitFor(() => { expect(container.querySelector(".tray-move-handle")).not.toBeNull(); }); @@ -943,6 +1022,9 @@ describe("TrayPanel provider grid", () => { 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 () => { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index c8354484a67..5d06840ff5f 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -6,7 +6,6 @@ import { costPeriodShortLabel } from "../lib/costPeriod"; import { useCurrency } from "../hooks/CurrencyProvider"; import { sumDisplayCurrencyAmounts } from "../lib/currency"; import { - beginFlyoutGesture, getUsageSpendSummary, openProviderDashboard, openProviderStatusPage, @@ -303,16 +302,14 @@ const RESIZE_GRIPS: ReadonlyArray<{ edge: string; direction: ResizeDirection }> /** * Window handles for the borderless flyout: a move strip along the top and - * invisible resize grips on every edge and corner. Native edge-resize doesn't - * reach through the borderless WebView2, 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 even - * though the user never left the window. We arm a gesture-scoped blur guard on - * the backend *before* starting the loop so that transient blur doesn't - * auto-hide the flyout; the guard clears itself once focus genuinely returns - * (via the `Focused(true)` refocus path) or after a 15s expiry, so no - * explicit end call is needed here — the OS loop swallows mouseup. + * 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. @@ -333,10 +330,9 @@ function TrayWindowHandles({ moveHint }: { moveHint: string }) { ); return; } - void (async () => { - await beginFlyoutGesture().catch(() => {}); - await getCurrentWindow().startDragging(); - })().catch((err) => console.error("[tray-move] startDragging failed:", err)); + void getCurrentWindow() + .startDragging() + .catch((err) => console.error("[tray-move] startDragging failed:", err)); }} > @@ -347,11 +343,11 @@ function TrayWindowHandles({ moveHint }: { moveHint: string }) { className={`tray-resize tray-resize--${edge}`} aria-hidden onMouseDown={(e) => { + if (e.button !== 0) return; e.preventDefault(); - void (async () => { - await beginFlyoutGesture().catch(() => {}); - await getCurrentWindow().startResizeDragging(direction); - })().catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); + void getCurrentWindow() + .startResizeDragging(direction) + .catch((err) => console.error("[tray-resize] startResizeDragging failed:", err)); }} /> ))}