From b7c72ee35ade635fc0bcbf62989f5b330a095650 Mon Sep 17 00:00:00 2001 From: peterbaikov Date: Fri, 28 Aug 2026 13:05:36 +0300 Subject: [PATCH] fix(linux): reliable tray panel clicks and bottom-tray positioning - Remove the full-screen click-catcher overlay window: on many WMs it stacked above the panel, eating clicks or closing the panel on any click inside it. Focus-loss handling already closes the panel, and dropping the overlay also removes an extra WebKit process. - Support bottom-docked trays: the panel now opens upward when it does not fit below the anchor, with the arrow flipped to point down (panel:anchor-edge event). Anchors carry the icon top edge (topY) via tray rect, GNOME extension, and the local HTTP API. - Fallback positioning without a tray rect now uses the cursor position when it is near a top/bottom screen edge (where trays live), instead of always the top-right corner; edge gating keeps the global shortcut from popping the panel mid-screen. - Drop eprintln! debug spam from the positioning hot path. Co-Authored-By: Claude Fable 5 --- src-tauri/capabilities/default.json | 4 +- .../gnome-anchor-extension/extension.js | 1 + src-tauri/src/local_http_api/server.rs | 29 +-- src-tauri/src/panel.rs | 169 ++++++++++++++---- src-tauri/src/panel_geometry.rs | 122 +++++++++---- src-tauri/src/panel_non_macos.rs | 160 +---------------- src/App.test.tsx | 14 -- src/App.tsx | 25 --- src/components/app/app-shell.tsx | 8 +- src/hooks/app/use-panel.test.ts | 8 +- src/hooks/app/use-panel.ts | 11 ++ src/index.css | 13 ++ 12 files changed, 297 insertions(+), 267 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 14fe0da68..116def23b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,9 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "panel-click-catcher"], + "windows": [ + "main" + ], "permissions": [ "core:default", "core:tray:default", diff --git a/src-tauri/resources/gnome-anchor-extension/extension.js b/src-tauri/resources/gnome-anchor-extension/extension.js index 22905cf55..87ab2c367 100644 --- a/src-tauri/resources/gnome-anchor-extension/extension.js +++ b/src-tauri/resources/gnome-anchor-extension/extension.js @@ -84,6 +84,7 @@ export default class OpenUsageAnchorExtension extends Extension { const body = JSON.stringify({ centerX: x + width / 2, + topY: y, bottomY: y + height, }); const request = [ diff --git a/src-tauri/src/local_http_api/server.rs b/src-tauri/src/local_http_api/server.rs index 4e4ef9e00..e13acb2f3 100644 --- a/src-tauri/src/local_http_api/server.rs +++ b/src-tauri/src/local_http_api/server.rs @@ -189,11 +189,23 @@ struct PanelOpenRequest { center_x: f64, #[serde(rename = "bottomY")] bottom_y: f64, + /// Optional icon top edge; defaults to bottomY for senders that don't provide it. + #[serde(rename = "topY")] + top_y: Option, +} + +impl PanelOpenRequest { + fn top_y(&self) -> f64 { + self.top_y.unwrap_or(self.bottom_y) + } } fn parse_panel_open_request(body: &str) -> Result { let request: PanelOpenRequest = serde_json::from_str(body).map_err(|_| "invalid_anchor")?; - if !request.center_x.is_finite() || !request.bottom_y.is_finite() { + if !request.center_x.is_finite() + || !request.bottom_y.is_finite() + || !request.top_y().is_finite() + { return Err("invalid_anchor"); } Ok(request) @@ -216,11 +228,12 @@ fn handle_post_linux_panel_open(body: &str, app_handle: Option<&AppHandle>) -> S request.center_x, request.bottom_y ); - eprintln!( - "linux panel open anchor center_x={:.0} bottom_y={:.0}", - request.center_x, request.bottom_y + crate::panel::show_panel_at_logical_anchor( + app_handle, + request.center_x, + request.top_y(), + request.bottom_y, ); - crate::panel::show_panel_at_logical_anchor(app_handle, request.center_x, request.bottom_y); response_no_content() } @@ -231,11 +244,7 @@ fn handle_post_linux_panel_anchor(body: &str) -> String { Err(error_code) => return response_bad_request(error_code), }; - eprintln!( - "linux panel remember anchor center_x={:.0} bottom_y={:.0}", - request.center_x, request.bottom_y - ); - crate::panel::remember_linux_panel_anchor(request.center_x, request.bottom_y); + crate::panel::remember_linux_panel_anchor(request.center_x, request.top_y(), request.bottom_y); response_no_content() } diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index aaab9c4fc..8c8753aad 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -8,11 +8,12 @@ use tauri_nspanel::{ }; use crate::panel_geometry::{ - LogicalAnchor, LogicalMonitorBounds, PanelAnchorPosition, compute_anchor_position, + AnchorEdge, LogicalAnchor, LogicalMonitorBounds, PanelAnchorPosition, compute_anchor_position, fallback_anchor_for_monitor, }; const PANEL_ANCHOR_OFFSET_EVENT: &str = "panel:anchor-offset"; +const PANEL_ANCHOR_EDGE_EVENT: &str = "panel:anchor-edge"; static LINUX_PANEL_ANCHOR: OnceLock>> = OnceLock::new(); #[cfg(target_os = "linux")] @@ -21,15 +22,19 @@ fn linux_panel_anchor_state() -> &'static Mutex> { } #[cfg(target_os = "linux")] -pub(crate) fn remember_linux_panel_anchor(center_x: f64, bottom_y: f64) { - if !center_x.is_finite() || !bottom_y.is_finite() { +pub(crate) fn remember_linux_panel_anchor(center_x: f64, top_y: f64, bottom_y: f64) { + if !center_x.is_finite() || !top_y.is_finite() || !bottom_y.is_finite() { return; } let mut anchor = linux_panel_anchor_state() .lock() .expect("linux panel anchor state poisoned"); - *anchor = Some(LogicalAnchor { center_x, bottom_y }); + *anchor = Some(LogicalAnchor { + center_x, + top_y, + bottom_y, + }); } #[cfg(target_os = "linux")] @@ -100,9 +105,16 @@ fn monitor_containing_logical_point<'a>( .find(|monitor| monitor_contains_logical_point(monitor, point_x, point_y)) } -fn emit_panel_anchor_offset(app_handle: &AppHandle, arrow_offset_px: f64) { - if let Err(error) = app_handle.emit(PANEL_ANCHOR_OFFSET_EVENT, arrow_offset_px) { - log::debug!("emit_panel_anchor_offset: failed: {}", error); +fn emit_panel_anchor(app_handle: &AppHandle, position: &PanelAnchorPosition) { + if let Err(error) = app_handle.emit(PANEL_ANCHOR_OFFSET_EVENT, position.arrow_offset_px) { + log::debug!("emit_panel_anchor: offset emit failed: {}", error); + } + let edge = match position.edge { + AnchorEdge::Top => "top", + AnchorEdge::Bottom => "bottom", + }; + if let Err(error) = app_handle.emit(PANEL_ANCHOR_EDGE_EVENT, edge) { + log::debug!("emit_panel_anchor: edge emit failed: {}", error); } } @@ -202,6 +214,7 @@ fn compute_panel_position( let anchor = LogicalAnchor { center_x: anchor_logical_x, + top_y: icon_logical_y, bottom_y: icon_logical_y + icon_logical_h, }; let position = compute_anchor_position(&monitor_bounds, anchor, panel_width, panel_height); @@ -293,6 +306,7 @@ fn compute_remembered_panel_position(_app_handle: &AppHandle) -> Option Option { let window = app_handle.get_webview_window("main")?; @@ -302,7 +316,11 @@ fn compute_logical_anchor_panel_position( .or_else(|| window.primary_monitor().ok().flatten())?; let monitor_bounds = logical_bounds_from_monitor(&monitor); let (panel_width, panel_height) = panel_size_from_window_or_config(&window); - let anchor = LogicalAnchor { center_x, bottom_y }; + let anchor = LogicalAnchor { + center_x, + top_y, + bottom_y, + }; let position = compute_anchor_position(&monitor_bounds, anchor, panel_width, panel_height); log::debug!( @@ -369,7 +387,7 @@ pub fn position_panel_at_tray_icon( return; }; apply_panel_position(app_handle, position.x, position.y, primary_logical_h); - emit_panel_anchor_offset(app_handle, position.arrow_offset_px); + emit_panel_anchor(app_handle, &position); } pub fn position_panel_at_tray_click( @@ -384,36 +402,93 @@ pub fn position_panel_at_tray_click( return; }; apply_panel_position(app_handle, position.x, position.y, primary_logical_h); - emit_panel_anchor_offset(app_handle, position.arrow_offset_px); + emit_panel_anchor(app_handle, &position); +} + +/// Cursor anchor is only trusted when the pointer sits near the top or bottom +/// screen edge — that's where trays live. This keeps the global shortcut +/// (cursor anywhere) from popping the panel mid-screen. +#[cfg(any(target_os = "linux", test))] +const CURSOR_ANCHOR_EDGE_ZONE_PX: f64 = 96.0; + +#[cfg(any(target_os = "linux", test))] +fn cursor_edge_anchor( + monitor_bounds: &LogicalMonitorBounds, + x: f64, + y: f64, +) -> Option { + let near_top = y <= monitor_bounds.y + CURSOR_ANCHOR_EDGE_ZONE_PX; + let near_bottom = y >= monitor_bounds.y + monitor_bounds.height - CURSOR_ANCHOR_EDGE_ZONE_PX; + if near_top || near_bottom { + Some(LogicalAnchor::at_point(x, y)) + } else { + None + } +} + +#[cfg(target_os = "linux")] +fn compute_cursor_edge_panel_position(app_handle: &AppHandle) -> Option { + let window = app_handle.get_webview_window("main")?; + let cursor = app_handle.cursor_position().ok()?; + let monitors = window.available_monitors().ok()?; + let monitor = monitor_containing_physical_point(&monitors, cursor.x, cursor.y)?; + let monitor_bounds = logical_bounds_from_monitor(monitor); + let scale = monitor.scale_factor(); + let cursor_x = monitor_bounds.x + (cursor.x - monitor.position().x as f64) / scale; + let cursor_y = monitor_bounds.y + (cursor.y - monitor.position().y as f64) / scale; + let anchor = cursor_edge_anchor(&monitor_bounds, cursor_x, cursor_y)?; + let (panel_width, panel_height) = panel_size_from_window_or_config(&window); + let position = compute_anchor_position(&monitor_bounds, anchor, panel_width, panel_height); + log::debug!( + "compute_cursor_edge_panel_position: cursor=({:.0},{:.0}) panel=({:.0},{:.0})", + cursor_x, + cursor_y, + position.x, + position.y + ); + Some(position) +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] +fn compute_cursor_edge_panel_position(_app_handle: &AppHandle) -> Option { + None } #[cfg(not(target_os = "macos"))] fn choose_fallback_panel_position( remembered: Option, + cursor_edge: Option, safe_fallback: Option, ) -> Option { - remembered.or(safe_fallback) + remembered.or(cursor_edge).or(safe_fallback) } #[cfg(not(target_os = "macos"))] fn position_panel_at_fallback_anchor(app_handle: &AppHandle) { let Some(position) = choose_fallback_panel_position( compute_remembered_panel_position(app_handle), + compute_cursor_edge_panel_position(app_handle), compute_fallback_panel_position(app_handle), ) else { return; }; apply_panel_position(app_handle, position.x, position.y, 0.0); - emit_panel_anchor_offset(app_handle, position.arrow_offset_px); + emit_panel_anchor(app_handle, &position); } -pub fn position_panel_at_logical_anchor(app_handle: &AppHandle, center_x: f64, bottom_y: f64) { - let Some(position) = compute_logical_anchor_panel_position(app_handle, center_x, bottom_y) +pub fn position_panel_at_logical_anchor( + app_handle: &AppHandle, + center_x: f64, + top_y: f64, + bottom_y: f64, +) { + let Some(position) = + compute_logical_anchor_panel_position(app_handle, center_x, top_y, bottom_y) else { return; }; apply_panel_position(app_handle, position.x, position.y, 0.0); - emit_panel_anchor_offset(app_handle, position.arrow_offset_px); + emit_panel_anchor(app_handle, &position); } #[cfg(test)] @@ -440,24 +515,49 @@ mod tests { } #[test] - fn fallback_position_does_not_use_cursor_anchor() { - let remembered = PanelAnchorPosition { - x: 10.0, - y: 20.0, - arrow_offset_px: 0.0, - }; - let safe = PanelAnchorPosition { - x: 30.0, - y: 40.0, + fn fallback_prefers_remembered_then_cursor_edge_then_safe() { + let position = |x: f64| PanelAnchorPosition { + x, + y: 0.0, arrow_offset_px: 0.0, + edge: AnchorEdge::Top, }; + let remembered = position(10.0); + let cursor = position(20.0); + let safe = position(30.0); + + assert_eq!( + choose_fallback_panel_position(None, None, Some(safe)) + .unwrap() + .x, + 30.0 + ); + assert_eq!( + choose_fallback_panel_position(None, Some(cursor), Some(safe)) + .unwrap() + .x, + 20.0 + ); + assert_eq!( + choose_fallback_panel_position(Some(remembered), Some(cursor), Some(safe)) + .unwrap() + .x, + 10.0 + ); + } - let chosen = choose_fallback_panel_position(None, Some(safe)).expect("safe fallback"); - assert_eq!(chosen.x, 30.0); + #[test] + fn cursor_anchor_only_near_screen_edges() { + let bounds = LogicalMonitorBounds { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; - let chosen = - choose_fallback_panel_position(Some(remembered), Some(safe)).expect("remembered"); - assert_eq!(chosen.x, 10.0); + assert!(cursor_edge_anchor(&bounds, 960.0, 20.0).is_some()); + assert!(cursor_edge_anchor(&bounds, 960.0, 1060.0).is_some()); + assert!(cursor_edge_anchor(&bounds, 960.0, 540.0).is_none()); } } @@ -601,11 +701,16 @@ mod platform { } } - pub fn show_panel_at_logical_anchor(app_handle: &AppHandle, center_x: f64, bottom_y: f64) { + pub fn show_panel_at_logical_anchor( + app_handle: &AppHandle, + center_x: f64, + top_y: f64, + bottom_y: f64, + ) { if let Some(panel) = get_or_init_panel!(app_handle) { - position_panel_at_logical_anchor(app_handle, center_x, bottom_y); + position_panel_at_logical_anchor(app_handle, center_x, top_y, bottom_y); panel.show_and_make_key(); - position_panel_at_logical_anchor(app_handle, center_x, bottom_y); + position_panel_at_logical_anchor(app_handle, center_x, top_y, bottom_y); } } diff --git a/src-tauri/src/panel_geometry.rs b/src-tauri/src/panel_geometry.rs index 5599258d7..2d64b105d 100644 --- a/src-tauri/src/panel_geometry.rs +++ b/src-tauri/src/panel_geometry.rs @@ -15,14 +15,38 @@ pub(crate) struct LogicalMonitorBounds { #[derive(Debug, Clone, Copy)] pub(crate) struct LogicalAnchor { pub(crate) center_x: f64, + /// Top edge of the anchored element (tray icon). Used when the panel + /// opens upward (tray at the bottom of the screen). + pub(crate) top_y: f64, + /// Bottom edge of the anchored element. Used when the panel opens downward. pub(crate) bottom_y: f64, } +impl LogicalAnchor { + /// Anchor with no known height (e.g. cursor position). + pub(crate) fn at_point(center_x: f64, y: f64) -> Self { + LogicalAnchor { + center_x, + top_y: y, + bottom_y: y, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AnchorEdge { + /// Panel sits below the anchor; arrow points up (tray at top). + Top, + /// Panel sits above the anchor; arrow points down (tray at bottom). + Bottom, +} + #[derive(Debug, Clone, Copy)] pub(crate) struct PanelAnchorPosition { pub(crate) x: f64, pub(crate) y: f64, pub(crate) arrow_offset_px: f64, + pub(crate) edge: AnchorEdge, } pub(crate) fn compute_anchor_position( @@ -35,9 +59,20 @@ pub(crate) fn compute_anchor_position( let min_x = monitor.x; let max_x = monitor.x + (monitor.width - panel_width).max(0.0); let x = desired_x.clamp(min_x, max_x); - let desired_y = anchor.bottom_y - PANEL_WINDOW_ARROW_TIP_TOP_OFFSET_PX; let min_y = monitor.y; let max_y = monitor.y + (monitor.height - panel_height).max(0.0); + + let below_y = anchor.bottom_y - PANEL_WINDOW_ARROW_TIP_TOP_OFFSET_PX; + let fits_below = below_y + panel_height <= monitor.y + monitor.height; + let (desired_y, edge) = if fits_below { + (below_y, AnchorEdge::Top) + } else { + // Tray at the bottom of the screen: open upward, panel bottom at the icon top. + ( + anchor.top_y + PANEL_WINDOW_ARROW_TIP_TOP_OFFSET_PX - panel_height, + AnchorEdge::Bottom, + ) + }; let y = desired_y.clamp(min_y, max_y); let arrow_offset_px = anchor.center_x - (x + (panel_width / 2.0)); @@ -45,14 +80,15 @@ pub(crate) fn compute_anchor_position( x, y, arrow_offset_px, + edge, } } pub(crate) fn fallback_anchor_for_monitor(monitor: &LogicalMonitorBounds) -> LogicalAnchor { - LogicalAnchor { - center_x: monitor.x + monitor.width - FALLBACK_ANCHOR_RIGHT_INSET_PX, - bottom_y: monitor.y + FALLBACK_TOP_PANEL_BOTTOM_Y_PX, - } + LogicalAnchor::at_point( + monitor.x + monitor.width - FALLBACK_ANCHOR_RIGHT_INSET_PX, + monitor.y + FALLBACK_TOP_PANEL_BOTTOM_Y_PX, + ) } #[cfg(test)] @@ -60,10 +96,7 @@ pub(crate) fn top_panel_anchor_at_x( monitor: &LogicalMonitorBounds, center_x: f64, ) -> LogicalAnchor { - LogicalAnchor { - center_x, - bottom_y: monitor.y + FALLBACK_TOP_PANEL_BOTTOM_Y_PX, - } + LogicalAnchor::at_point(center_x, monitor.y + FALLBACK_TOP_PANEL_BOTTOM_Y_PX) } #[cfg(test)] @@ -78,16 +111,14 @@ mod tests { width: 1920.0, height: 1080.0, }; - let anchor = LogicalAnchor { - center_x: 960.0, - bottom_y: 28.0, - }; + let anchor = LogicalAnchor::at_point(960.0, 28.0); let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); assert_eq!(result.x, 760.0); assert_eq!(result.y, 22.0); assert_eq!(result.arrow_offset_px, 0.0); + assert_eq!(result.edge, AnchorEdge::Top); } #[test] @@ -98,10 +129,7 @@ mod tests { width: 1920.0, height: 1080.0, }; - let anchor = LogicalAnchor { - center_x: 1900.0, - bottom_y: 28.0, - }; + let anchor = LogicalAnchor::at_point(1900.0, 28.0); let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); @@ -125,6 +153,7 @@ mod tests { assert_eq!(result.x, 1520.0); assert_eq!(result.y, 26.0); assert_eq!(result.arrow_offset_px, 152.0); + assert_eq!(result.edge, AnchorEdge::Top); } #[test] @@ -135,10 +164,7 @@ mod tests { width: 800.0, height: 1080.0, }; - let anchor = LogicalAnchor { - center_x: 130.0, - bottom_y: 28.0, - }; + let anchor = LogicalAnchor::at_point(130.0, 28.0); let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); @@ -148,20 +174,60 @@ mod tests { } #[test] - fn y_position_clamps_inside_monitor() { + fn bottom_tray_opens_panel_upward() { let monitor = LogicalMonitorBounds { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0, }; + // Tray icon at the bottom edge: top=1052, bottom=1076. let anchor = LogicalAnchor { center_x: 960.0, - bottom_y: 1200.0, + top_y: 1052.0, + bottom_y: 1076.0, }; let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); + // Panel bottom (plus arrow tip inset) touches the icon top. + assert_eq!( + result.y, + 1052.0 + PANEL_WINDOW_ARROW_TIP_TOP_OFFSET_PX - 500.0 + ); + assert_eq!(result.edge, AnchorEdge::Bottom); + assert_eq!(result.arrow_offset_px, 0.0); + } + + #[test] + fn bottom_anchor_without_height_still_opens_upward() { + let monitor = LogicalMonitorBounds { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let anchor = LogicalAnchor::at_point(960.0, 1075.0); + + let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); + + assert_eq!(result.edge, AnchorEdge::Bottom); + // Desired 581 clamps to the monitor's max panel top (1080 - 500). + assert_eq!(result.y, 580.0); + } + + #[test] + fn y_position_clamps_inside_monitor() { + let monitor = LogicalMonitorBounds { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let anchor = LogicalAnchor::at_point(960.0, 1200.0); + + let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); + assert_eq!(result.x, 760.0); assert_eq!(result.y, 580.0); assert_eq!(result.arrow_offset_px, 0.0); @@ -175,10 +241,7 @@ mod tests { width: 1920.0, height: 1080.0, }; - let anchor = LogicalAnchor { - center_x: 1440.0, - bottom_y: 32.0, - }; + let anchor = LogicalAnchor::at_point(1440.0, 32.0); let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); @@ -212,10 +275,7 @@ mod tests { width: 1920.0, height: 1080.0, }; - let anchor = LogicalAnchor { - center_x: 1440.0, - bottom_y: 32.0, - }; + let anchor = LogicalAnchor::at_point(1440.0, 32.0); let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); let arrow_tip_y = result.y + PANEL_WINDOW_ARROW_TIP_TOP_OFFSET_PX; diff --git a/src-tauri/src/panel_non_macos.rs b/src-tauri/src/panel_non_macos.rs index 5d6425b11..bb1c046cd 100644 --- a/src-tauri/src/panel_non_macos.rs +++ b/src-tauri/src/panel_non_macos.rs @@ -1,9 +1,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{ - AppHandle, LogicalPosition, LogicalSize, Manager, PhysicalPosition, Position, Size, WebviewUrl, - WebviewWindowBuilder, -}; +use tauri::{AppHandle, Manager, PhysicalPosition, Position, Size}; #[cfg(target_os = "linux")] use gtk::prelude::*; @@ -12,20 +9,10 @@ use crate::panel::{ position_panel_at_logical_anchor, position_panel_at_tray_click, position_panel_from_tray, }; -const CLICK_CATCHER_LABEL: &str = "panel-click-catcher"; -const CLICK_CATCHER_URL: &str = "index.html?overlay=panel-click-catcher"; #[cfg(target_os = "linux")] static LINUX_FOCUS_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); static PANEL_IS_OPEN: AtomicBool = AtomicBool::new(false); -#[derive(Clone, Copy)] -struct LogicalOverlayBounds { - x: f64, - y: f64, - width: f64, - height: f64, -} - fn register_panel_opened() { PANEL_IS_OPEN.store(true, Ordering::SeqCst); } @@ -52,112 +39,6 @@ fn present_gtk_window(window: &tauri::WebviewWindow) { #[cfg(not(target_os = "linux"))] fn present_gtk_window(_window: &tauri::WebviewWindow) {} -fn monitor_logical_bounds(monitor: &tauri::Monitor) -> LogicalOverlayBounds { - let scale = monitor.scale_factor(); - LogicalOverlayBounds { - x: monitor.position().x as f64 / scale, - y: monitor.position().y as f64 / scale, - width: monitor.size().width as f64 / scale, - height: monitor.size().height as f64 / scale, - } -} - -fn merge_overlay_bounds( - current: Option, - next: LogicalOverlayBounds, -) -> LogicalOverlayBounds { - match current { - Some(current) => { - let min_x = current.x.min(next.x); - let min_y = current.y.min(next.y); - let max_x = (current.x + current.width).max(next.x + next.width); - let max_y = (current.y + current.height).max(next.y + next.height); - LogicalOverlayBounds { - x: min_x, - y: min_y, - width: max_x - min_x, - height: max_y - min_y, - } - } - None => next, - } -} - -fn click_catcher_bounds(window: &tauri::WebviewWindow) -> Option { - let monitors = window.available_monitors().ok()?; - let mut bounds = None; - for monitor in &monitors { - bounds = Some(merge_overlay_bounds( - bounds, - monitor_logical_bounds(monitor), - )); - } - bounds -} - -fn get_or_create_click_catcher(app_handle: &AppHandle) -> Option { - if let Some(window) = app_handle.get_webview_window(CLICK_CATCHER_LABEL) { - return Some(window); - } - - match WebviewWindowBuilder::new( - app_handle, - CLICK_CATCHER_LABEL, - WebviewUrl::App(CLICK_CATCHER_URL.into()), - ) - .title("") - .decorations(false) - .transparent(true) - .resizable(false) - .skip_taskbar(true) - .always_on_top(true) - .visible(false) - .focused(false) - .focusable(false) - .shadow(false) - .inner_size(1.0, 1.0) - .build() - { - Ok(window) => Some(window), - Err(error) => { - log::warn!("click catcher: failed to create overlay window: {error}"); - None - } - } -} - -fn should_show_click_catcher() -> bool { - true -} - -fn show_click_catcher(app_handle: &AppHandle) { - if !should_show_click_catcher() { - return; - } - - let Some(main_window) = app_handle.get_webview_window("main") else { - return; - }; - let Some(click_catcher) = get_or_create_click_catcher(app_handle) else { - return; - }; - - if let Some(bounds) = click_catcher_bounds(&main_window) { - let _ = click_catcher.set_position(LogicalPosition::new(bounds.x, bounds.y)); - let _ = click_catcher.set_size(LogicalSize::new(bounds.width, bounds.height)); - } - - let _ = click_catcher.set_always_on_top(true); - let _ = click_catcher.set_focusable(false); - let _ = click_catcher.show(); -} - -fn hide_click_catcher(app_handle: &AppHandle) { - if let Some(window) = app_handle.get_webview_window(CLICK_CATCHER_LABEL) { - let _ = window.hide(); - } -} - pub(crate) fn apply_panel_position( app_handle: &AppHandle, panel_x: f64, @@ -172,28 +53,11 @@ pub(crate) fn apply_panel_position( panel_x, panel_y ); - eprintln!( - "apply_panel_position requested logical=({:.0},{:.0})", - panel_x, panel_y - ); if let Err(e) = window.set_position(tauri::LogicalPosition::new(panel_x, panel_y)) { log::warn!( "apply_panel_position: set_position failed (best-effort): {}", e ); - eprintln!("apply_panel_position set_position failed: {e}"); - return; - } - match window.outer_position() { - Ok(position) => { - eprintln!( - "apply_panel_position actual outer physical=({},{})", - position.x, position.y - ); - } - Err(error) => { - eprintln!("apply_panel_position actual outer position unavailable: {error}"); - } } } @@ -240,7 +104,6 @@ pub fn show_panel(app_handle: &AppHandle) { return; }; if window.is_visible().unwrap_or(false) { - show_click_catcher(app_handle); let _ = window.set_always_on_top(true); let _ = window.set_focus(); present_gtk_window(&window); @@ -248,7 +111,6 @@ pub fn show_panel(app_handle: &AppHandle) { return; } - show_click_catcher(app_handle); let _ = window.set_always_on_top(true); position_panel_from_tray(app_handle); let _ = window.show(); @@ -267,7 +129,6 @@ fn show_panel_at_tray_icon( let Some(window) = app_handle.get_webview_window("main") else { return; }; - show_click_catcher(app_handle); let _ = window.set_always_on_top(true); position_panel_at_tray_click(app_handle, click_position, icon_position, icon_size); let _ = window.show(); @@ -277,15 +138,19 @@ fn show_panel_at_tray_icon( register_panel_opened(); } -pub fn show_panel_at_logical_anchor(app_handle: &AppHandle, center_x: f64, bottom_y: f64) { +pub fn show_panel_at_logical_anchor( + app_handle: &AppHandle, + center_x: f64, + top_y: f64, + bottom_y: f64, +) { let Some(window) = app_handle.get_webview_window("main") else { return; }; - show_click_catcher(app_handle); let _ = window.set_always_on_top(true); - position_panel_at_logical_anchor(app_handle, center_x, bottom_y); + position_panel_at_logical_anchor(app_handle, center_x, top_y, bottom_y); let _ = window.show(); - position_panel_at_logical_anchor(app_handle, center_x, bottom_y); + position_panel_at_logical_anchor(app_handle, center_x, top_y, bottom_y); let _ = window.set_focus(); present_gtk_window(&window); register_panel_opened(); @@ -325,7 +190,6 @@ pub fn toggle_panel_at_tray_icon( pub fn hide_panel(app_handle: &AppHandle) { register_panel_closed(); - hide_click_catcher(app_handle); if let Some(window) = app_handle.get_webview_window("main") { let _ = window.hide(); } @@ -340,12 +204,6 @@ mod tests { PANEL_IS_OPEN.store(false, Ordering::SeqCst); } - #[cfg(target_os = "linux")] - #[test] - fn linux_uses_click_catcher_overlay() { - assert!(should_show_click_catcher()); - } - #[test] fn visible_open_panel_hides_on_focus_loss() { assert!(should_hide_for_focus_loss(true, true)); diff --git a/src/App.test.tsx b/src/App.test.tsx index f7fb22b42..d2c0d6616 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -383,20 +383,6 @@ describe("App", () => { return contextAction as () => void } - it("renders click catcher overlay and hides panel on pointer down", async () => { - window.history.replaceState(null, "", "/?overlay=panel-click-catcher") - state.isTauriMock.mockReturnValue(true) - - render() - - const overlay = screen.getByTestId("panel-click-catcher") - fireEvent.pointerDown(overlay) - - await waitFor(() => { - expect(state.invokeMock).toHaveBeenCalledWith("hide_panel") - }) - expect(state.startBatchMock).not.toHaveBeenCalled() - }) it("applies theme mode changes to document", async () => { const mq = { diff --git a/src/App.tsx b/src/App.tsx index 6fd4a13fe..acdf45ff1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef } from "react" -import { invoke } from "@tauri-apps/api/core" import { useShallow } from "zustand/react/shallow" import { AppShell } from "@/components/app/app-shell" import { useAppPluginViews } from "@/hooks/app/use-app-plugin-views" @@ -20,31 +19,7 @@ import { useAppUiStore } from "@/stores/app-ui-store" const TRAY_PROBE_DEBOUNCE_MS = 500 const TRAY_SETTINGS_DEBOUNCE_MS = 2000 -function isPanelClickCatcherOverlay(): boolean { - if (typeof window === "undefined") return false - return new URLSearchParams(window.location.search).get("overlay") === "panel-click-catcher" -} - -function PanelClickCatcherOverlay() { - const hidePanel = useCallback(() => { - invoke("hide_panel").catch(console.error) - }, []) - - return ( -
- ) -} - function App() { - if (isPanelClickCatcherOverlay()) { - return - } - const { activeView, setActiveView, diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index 2c76d1a01..9eeadb9bc 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -56,6 +56,7 @@ export function AppShell({ canScrollDown, maxPanelHeightPx, arrowOffsetPx, + anchorEdge, } = usePanel({ activeView, setActiveView, @@ -71,9 +72,12 @@ export function AppShell({
-
+
{ ) await waitFor(() => { - expect(listenMock).toHaveBeenCalledTimes(3) + expect(listenMock).toHaveBeenCalledTimes(4) }) act(() => { @@ -181,6 +181,12 @@ describe("usePanel", () => { }) expect(result.current.arrowOffsetPx).toBe(72) + + expect(result.current.anchorEdge).toBe("top") + act(() => { + callbacks.get("panel:anchor-edge")?.({ payload: "bottom" }) + }) + expect(result.current.anchorEdge).toBe("bottom") }) it("switches views with Cmd+Arrow navigation", () => { diff --git a/src/hooks/app/use-panel.ts b/src/hooks/app/use-panel.ts index 77c0f95c6..f1746843f 100644 --- a/src/hooks/app/use-panel.ts +++ b/src/hooks/app/use-panel.ts @@ -44,6 +44,7 @@ export function usePanel({ const [canScrollDown, setCanScrollDown] = useState(false) const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) const [arrowOffsetPx, setArrowOffsetPx] = useState(0) + const [anchorEdge, setAnchorEdge] = useState<"top" | "bottom">("top") const maxPanelHeightPxRef = useRef(null) const focusContainer = useCallback(() => { window.requestAnimationFrame(() => { @@ -120,6 +121,15 @@ export function usePanel({ return } unlisteners.push(u3) + + const u4 = await listen("panel:anchor-edge", (event) => { + setAnchorEdge(event.payload === "bottom" ? "bottom" : "top") + }) + if (cancelled) { + u4() + return + } + unlisteners.push(u4) } void setup() @@ -258,5 +268,6 @@ export function usePanel({ canScrollDown, maxPanelHeightPx, arrowOffsetPx, + anchorEdge, } } diff --git a/src/index.css b/src/index.css index 8a71d401d..eabfdd37b 100644 --- a/src/index.css +++ b/src/index.css @@ -179,6 +179,19 @@ body, border-bottom: 6px solid var(--card); } +/* Tray at the bottom of the screen: arrow sits below the card and points down */ +.tray-arrow-bottom { + order: 1; + border-bottom: none; + border-top: 7px solid var(--border); +} +.tray-arrow-bottom::after { + top: auto; + bottom: 1.5px; + border-bottom: none; + border-top: 6px solid var(--card); +} + /* Border beam for the update button */ .update-border-beam { position: relative;