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.
This commit is contained in:
Peter Steinberger
2026-07-27 00:05:52 -04:00
committed by GitHub
parent 95f56b84cb
commit 730b341f3f
2 changed files with 113 additions and 11 deletions

View File

@@ -478,7 +478,28 @@ fn ensure_quickchat_window(app: &AppHandle) -> Result<WebviewWindow, String> {
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);
}
}

View File

@@ -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<bool, String> {
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(|_| {