From 730b341f3fec14d17a2c763d696764031229b77c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 00:05:52 -0400 Subject: [PATCH] fix(linux): keep Quick Chat on screen across DPI and widget resizes (#114271) Two geometry defects found while stress-testing the companion. Positioning mixed coordinate spaces. `work_area()` is physical pixels of the monitor Quick Chat is moving to, but `inner_size()` is physical pixels at the scale of the monitor it is on now. A 640pt window on a 2x display reports 1280px, so invoking it on a 1x 1920px display centred using 1280 and landed it 320px left of centre. Re-express the window size in the target monitor's scale first; equal scales give a ratio of 1, so single-monitor setups are untouched to the pixel. Widget growth resized without re-anchoring. `quickchat_set_expanded` resizes and then repositions, but the widget path only resized, keeping the old top edge. Growing from 360pt to 440pt in a 440pt work area left the bottom 80pt off-screen and unreachable. `resize_window_if_needed` now reports whether it actually changed the height so the caller re-anchors only on a real resize, rather than moving the window when nothing changed. --- apps/linux/src-tauri/src/quickchat.rs | 93 ++++++++++++++++++- apps/linux/src-tauri/src/quickchat_widgets.rs | 31 +++++-- 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/apps/linux/src-tauri/src/quickchat.rs b/apps/linux/src-tauri/src/quickchat.rs index 7f1f06306926..f6bdfc5c3d39 100644 --- a/apps/linux/src-tauri/src/quickchat.rs +++ b/apps/linux/src-tauri/src/quickchat.rs @@ -478,7 +478,28 @@ fn ensure_quickchat_window(app: &AppHandle) -> Result { Ok(window) } -fn position_quickchat(app: &AppHandle, window: &Window) -> Result<(), String> { +/// Re-express a window's physical size in a target monitor's physical pixels. +/// +/// `inner_size()` reports physical pixels at the scale of the monitor the +/// window is on *now*, while `work_area()` is physical pixels of the monitor we +/// are about to move to. Comparing them directly misplaces Quick Chat across a +/// mixed-DPI boundary: a 640pt window on a 2x display reports 1280px, so +/// centring it on a 1x display uses double its real width. Scales that are +/// equal (the single-monitor case) give a ratio of 1 and change nothing. +pub(crate) fn quickchat_target_size( + window_physical: (f64, f64), + window_scale: f64, + monitor_scale: f64, +) -> (f64, f64) { + let usable = |scale: f64| scale.is_finite() && scale > 0.0; + if !usable(window_scale) || !usable(monitor_scale) { + return window_physical; + } + let ratio = monitor_scale / window_scale; + (window_physical.0 * ratio, window_physical.1 * ratio) +} + +pub(crate) fn position_quickchat(app: &AppHandle, window: &Window) -> Result<(), String> { let monitor = app .cursor_position() .ok() @@ -490,10 +511,18 @@ fn position_quickchat(app: &AppHandle, window: &Window) -> Result<(), String> { let window_size = window .inner_size() .map_err(|error| format!("Could not read Quick Chat size: {error}"))?; + let window_scale = window + .scale_factor() + .map_err(|error| format!("Could not read Quick Chat scale: {error}"))?; + let target_size = quickchat_target_size( + (window_size.width as f64, window_size.height as f64), + window_scale, + monitor.scale_factor(), + ); let (x, y) = quickchat_position( (work_area.position.x as f64, work_area.position.y as f64), (work_area.size.width as f64, work_area.size.height as f64), - (window_size.width as f64, window_size.height as f64), + target_size, ); window .set_position(PhysicalPosition::new(x.round() as i32, y.round() as i32)) @@ -981,4 +1010,64 @@ mod tests { state.set_shortcut_registered(false); assert!(!state.matches_shortcut(&custom)); } + + #[test] + fn target_size_is_identity_within_one_monitor() { + // The single-monitor case must not move by even a pixel. + assert_eq!( + quickchat_target_size((1280.0, 720.0), 2.0, 2.0), + (1280.0, 720.0) + ); + assert_eq!( + quickchat_target_size((640.0, 360.0), 1.0, 1.0), + (640.0, 360.0) + ); + assert_eq!( + quickchat_target_size((960.0, 540.0), 1.5, 1.5), + (960.0, 540.0) + ); + } + + #[test] + fn target_size_rescales_across_a_dpi_boundary() { + // A 640pt window on a 2x display reports 1280px; on a 1x display it is + // really 640px wide, and centring with 1280 would sit it far left. + assert_eq!( + quickchat_target_size((1280.0, 720.0), 2.0, 1.0), + (640.0, 360.0) + ); + // And the reverse direction. + assert_eq!( + quickchat_target_size((640.0, 360.0), 1.0, 2.0), + (1280.0, 720.0) + ); + // Fractional scales, as Windows and some Linux compositors report. + assert_eq!( + quickchat_target_size((1200.0, 600.0), 2.0, 1.5), + (900.0, 450.0) + ); + } + + #[test] + fn target_size_falls_back_on_unusable_scales() { + for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!( + quickchat_target_size((800.0, 600.0), scale, 2.0), + (800.0, 600.0) + ); + assert_eq!( + quickchat_target_size((800.0, 600.0), 2.0, scale), + (800.0, 600.0) + ); + } + } + + #[test] + fn rescaled_window_centers_on_the_target_monitor() { + // End to end: a 2x-scaled 640pt-wide window invoked on a 1x 1920px + // monitor. Without the rescale it centers using 1280 and lands at 320. + let target = quickchat_target_size((1280.0, 720.0), 2.0, 1.0); + let (x, _) = quickchat_position((0.0, 0.0), (1920.0, 1080.0), target); + assert_eq!(x, 640.0); + } } diff --git a/apps/linux/src-tauri/src/quickchat_widgets.rs b/apps/linux/src-tauri/src/quickchat_widgets.rs index dbf510c97528..aaf5abfc13ad 100644 --- a/apps/linux/src-tauri/src/quickchat_widgets.rs +++ b/apps/linux/src-tauri/src/quickchat_widgets.rs @@ -1,5 +1,5 @@ use crate::gateway_ws::GatewayClient; -use crate::quickchat::{QuickChatState, QUICKCHAT_LABEL}; +use crate::quickchat::{position_quickchat, QuickChatState, QUICKCHAT_LABEL}; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::collections::HashSet; @@ -60,7 +60,9 @@ fn quickchat_window_height(has_widgets: bool, expanded: bool) -> f64 { } } -fn resize_window_if_needed(window: &Window, height: f64) -> Result<(), String> { +/// Returns whether the window was actually resized, so the caller can re-anchor +/// it only when its height really changed. +fn resize_window_if_needed(window: &Window, height: f64) -> Result { let scale = window .scale_factor() .map_err(|error| format!("Could not read Quick Chat scale: {error}"))?; @@ -69,11 +71,12 @@ fn resize_window_if_needed(window: &Window, height: f64) -> Result<(), String> { .map_err(|error| format!("Could not read Quick Chat size: {error}"))?; let current_height = f64::from(current.height) / scale; if (current_height - height).abs() <= 0.5 { - return Ok(()); + return Ok(false); } window .set_size(LogicalSize::new(QUICKCHAT_WIDTH, height)) - .map_err(|error| format!("Could not resize Quick Chat for widgets: {error}")) + .map_err(|error| format!("Could not resize Quick Chat for widgets: {error}"))?; + Ok(true) } impl QuickChatWidgetState { @@ -488,11 +491,21 @@ impl QuickChatWidgetState { )); } } - if let Err(error) = - resize_window_if_needed(&window, quickchat_window_height(has_widgets, expanded)) - { - cleanup_created(&created); - return Err(error); + match resize_window_if_needed(&window, quickchat_window_height(has_widgets, expanded)) { + // Growing for widgets has to re-anchor the window the same way the + // expand path does. Resizing alone keeps the old top edge, so a + // taller window can hang off the bottom of the work area. + Ok(true) => { + if let Err(error) = position_quickchat(window.app_handle(), &window) { + cleanup_created(&created); + return Err(error); + } + } + Ok(false) => {} + Err(error) => { + cleanup_created(&created); + return Err(error); + } } *self.views.lock().map_err(|_| {