fix(tauri): clamp window to one monitor on mixed-DPI multi-monitor (#5041) (#5221)

This commit is contained in:
Mega Mind
2026-07-27 22:45:48 +05:30
committed by GitHub
parent cf71f557c4
commit dcc5b9b251
2 changed files with 597 additions and 35 deletions
+10
View File
@@ -3382,6 +3382,16 @@ pub fn run() {
// / `center: false` for the main window so the placement
// happens before the first paint and there's no jump.
if let Some(window) = app.get_webview_window("main") {
// Layout first: mixed-DPI placement bugs (#5041) are not
// diagnosable from a user report without it, and logging
// before placement captures the pre-clamp state.
window_state::log_monitor_layout(&window);
// Installed before placement so a scale change triggered
// by our own cross-monitor move is caught too — on
// Windows that arrives via the message loop after
// `setup()` returns, which is why clamping here alone is
// not enough.
window_state::install_dpi_guard(&window);
if !window_state::restore_main(&window) {
window_state::center_main(&window);
}
+587 -35
View File
@@ -26,11 +26,32 @@
//! on small or scaled displays — see issue #2282. We also re-clamp on
//! restore so a window saved on a large external display does not come
//! back oversized after the user undocks onto a small laptop screen.
//!
//! ## Mixed-DPI multi-monitor (#5041)
//!
//! Every coordinate in this module is a **physical** pixel:
//! `Monitor::work_area()`, `outer_position()` and `outer_size()` all
//! speak physical, so there is no logical/physical conversion to get
//! wrong. The DPI hazard is elsewhere — Windows preserves a window's
//! *logical* size across a monitor change, so moving a window from a
//! 100 % display to a 150 % one makes the OS multiply its physical size
//! by 1.5 on arrival. A size that was correctly clamped to the
//! destination monitor before the move therefore arrives 1.5x too large.
//!
//! Two mitigations, because the OS rescale is asynchronous:
//!
//! 1. [`center_main`] targets the monitor the window is *already* on
//! (avoiding the cross-DPI move entirely), and when a move is
//! unavoidable it positions **before** sizing so the rescale lands
//! first and the applied size is measured against the destination.
//! 2. [`install_dpi_guard`] re-clamps on `ScaleFactorChanged`, which is
//! delivered through the message loop *after* `setup()` has returned
//! — the case no amount of startup clamping can catch.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::{PhysicalPosition, PhysicalSize, Runtime, WebviewWindow};
use tauri::{PhysicalPosition, PhysicalSize, Runtime, WebviewWindow, WindowEvent};
use crate::cef_profile;
@@ -59,15 +80,23 @@ struct WindowState {
height: u32,
}
/// A monitor's usable work area in physical pixels. Plain-data struct so
/// the geometry math in [`clamp_to_work_area`] / [`pick_monitor_for_window`]
/// can be unit-tested without a live Tauri runtime.
/// A monitor's usable work area in physical pixels, plus its DPI scale
/// factor. Plain-data struct so the geometry math in
/// [`clamp_to_work_area`] / [`pick_monitor_for_window`] /
/// [`dpi_adjusted_size`] can be unit-tested without a live Tauri runtime.
///
/// `scale` is only meaningful for cross-monitor moves on Windows — see
/// [`dpi_adjusted_size`]. All four geometry fields are physical pixels,
/// matching `Monitor::work_area()`, `outer_position()` and
/// `outer_size()`, so no logical/physical conversion happens anywhere in
/// this module.
#[derive(Debug, Clone, Copy)]
struct WorkArea {
x: i32,
y: i32,
width: u32,
height: u32,
scale: f64,
}
fn state_path() -> Option<PathBuf> {
@@ -184,13 +213,19 @@ pub fn restore_main<R: Runtime>(window: &WebviewWindow<R>) -> bool {
let (x, y, width, height) =
clamp_to_work_area(state.x, state.y, state.width, state.height, monitor);
if let Err(err) = window.set_size(PhysicalSize::new(width, height)) {
log::warn!("[window-state] set_size failed: {err}");
}
// Position before size (#5041). The saved geometry may belong to a
// monitor with a different scale factor than the one the window was
// just created on; moving first lets the OS apply its DPI rescale,
// so the size we then set is the size that sticks. The reverse order
// lets Windows multiply our just-applied size by the DPI ratio on
// arrival. `install_dpi_guard` still backstops the async case.
if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) {
log::warn!("[window-state] set_position failed: {err}");
return false;
}
if let Err(err) = window.set_size(PhysicalSize::new(width, height)) {
log::warn!("[window-state] set_size failed: {err}");
}
if (x, y, width, height) != (state.x, state.y, state.width, state.height) {
log::info!(
"[window-state] restored geometry clamped to work area: saved x={} y={} w={} h={} -> applied x={} y={} w={} h={}",
@@ -223,24 +258,92 @@ pub fn restore_main<R: Runtime>(window: &WebviewWindow<R>) -> bool {
/// not exceed the user's actual screen on small or scaled displays
/// (issue #2282).
pub fn center_main<R: Runtime>(window: &WebviewWindow<R>) {
let Some(monitor) = primary_or_current_work_area(window) else {
let _ = window.center();
return;
};
let Ok(size) = window.outer_size() else {
let _ = window.center();
return;
};
let work_areas = collect_work_areas(window);
let primary = primary_or_current_work_area(window);
// Where the window currently sits drives monitor selection. With no
// readable position there is nothing to overlap-test, so go straight
// to the primary.
let target = match window.outer_position() {
Ok(pos) => target_work_area_for_new_window(
pos.x,
pos.y,
size.width,
size.height,
&work_areas,
primary,
),
Err(err) => {
log::warn!("[window-state] outer_position unavailable ({err}); targeting primary");
primary.or_else(|| work_areas.first().copied())
}
};
let Some(monitor) = target else {
let _ = window.center();
return;
};
// Scale factor the window is living at *right now*. On a cross-DPI
// move the OS will rescale by `monitor.scale / current_scale`, so we
// need both halves of the ratio.
let current_scale = window
.current_monitor()
.ok()
.flatten()
.map(|m| m.scale_factor())
.or_else(|| window.scale_factor().ok())
.unwrap_or(monitor.scale);
// Move onto the target monitor BEFORE committing a size (#5041).
//
// Ordering is load-bearing: `set_size` then `set_position` lets
// Windows rescale the just-applied size on arrival, which is exactly
// how a correctly-clamped window ends up 1.5x too wide. Positioning
// first lets the DPI change land, so the size we then apply is
// measured against the monitor the window is actually on.
if (monitor.scale - current_scale).abs() > f64::EPSILON {
let (predicted_w, predicted_h) =
dpi_adjusted_size(size.width, size.height, current_scale, monitor.scale);
log::info!(
"[window-state] cross-DPI placement: scale {} -> {}; size {}x{} would become {}x{} on arrival; positioning before sizing",
current_scale,
monitor.scale,
size.width,
size.height,
predicted_w,
predicted_h,
);
if let Err(err) = window.set_position(PhysicalPosition::new(monitor.x, monitor.y)) {
log::warn!("[window-state] pre-size set_position failed: {err}");
}
}
// Re-read the size: on the cross-DPI path above the OS may already
// have rescaled us, so `size` is stale and clamping it would apply
// the wrong number.
let settled = window.outer_size().unwrap_or(size);
if (settled.width, settled.height) != (size.width, size.height) {
log::info!(
"[window-state] OS rescaled window during placement: {}x{} -> {}x{}",
size.width,
size.height,
settled.width,
settled.height,
);
}
// Resolve the new size first; if the default exceeds work area we
// shrink before centering so the centered position is computed
// against the actually-applied size, not the oversized default.
let (clamped_w, clamped_h) = clamp_size(size.width, size.height, &monitor);
if (clamped_w, clamped_h) != (size.width, size.height) {
let (clamped_w, clamped_h) = clamp_size(settled.width, settled.height, &monitor);
if (clamped_w, clamped_h) != (settled.width, settled.height) {
log::info!(
"[window-state] default size {}x{} exceeds work area {}x{}; shrinking to {}x{}",
size.width,
size.height,
"[window-state] size {}x{} exceeds work area {}x{}; shrinking to {}x{}",
settled.width,
settled.height,
monitor.width,
monitor.height,
clamped_w,
@@ -267,22 +370,129 @@ pub fn center_main<R: Runtime>(window: &WebviewWindow<R>) {
}
}
/// Re-clamp the window into its current monitor's work area after the OS
/// has changed its scale factor.
///
/// `center_main` and `restore_main` both run inside Tauri's `setup()`
/// hook, but `WM_DPICHANGED` is delivered through the message loop
/// *after* `setup()` returns. Any size the OS imposes on arrival at a
/// different-DPI monitor therefore lands after our clamping has already
/// finished — the "clamping runs too late" half of #5041. This handler
/// is the durable fix: whenever the scale factor changes, re-fit the
/// window to whichever monitor it is now on.
///
/// It also covers the case the startup path cannot: the user dragging
/// the window from a 100 % monitor onto a 150 % one at any point during
/// the session, where the same OS rescale applies.
fn reclamp_after_scale_change<R: Runtime>(window: &WebviewWindow<R>, new_scale: f64) {
// Maximized and fullscreen windows intentionally carry geometry that can
// exceed the work area, and the OS owns it in those states. Clamping here
// would issue `set_size`/`set_position` that unmaximizes the window, or
// overwrite the bounds the OS restores on leaving fullscreen. Both states
// are reachable from the app's own `toggleMaximize()` and the native
// fullscreen menu, so this is a normal path, not an edge case.
//
// A failed query is treated as "not in that state": the pre-#5041 behaviour
// was to always clamp, so falling back to clamping keeps the fix working
// rather than silently disabling it on a backend that cannot answer.
if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false) {
log::debug!(
"[window-state] scale change to {new_scale}; window is maximized/fullscreen — leaving geometry to the OS"
);
return;
}
let Ok(Some(monitor)) = window.current_monitor() else {
log::warn!("[window-state] scale change but current_monitor unavailable; skip re-clamp");
return;
};
let work_area = work_area_of(&monitor);
let Ok(pos) = window.outer_position() else {
log::warn!("[window-state] scale change but outer_position unavailable; skip re-clamp");
return;
};
let Ok(size) = window.outer_size() else {
log::warn!("[window-state] scale change but outer_size unavailable; skip re-clamp");
return;
};
let (x, y, w, h) = clamp_to_work_area(pos.x, pos.y, size.width, size.height, work_area);
if (x, y, w, h) == (pos.x, pos.y, size.width, size.height) {
log::debug!(
"[window-state] scale change to {new_scale}; geometry {}x{} at ({},{}) already fits work area",
size.width,
size.height,
pos.x,
pos.y
);
return;
}
log::info!(
"[window-state] scale change to {new_scale}; re-clamping {}x{} at ({},{}) -> {}x{} at ({},{}) for work area ({},{} {}x{})",
size.width,
size.height,
pos.x,
pos.y,
w,
h,
x,
y,
work_area.x,
work_area.y,
work_area.width,
work_area.height,
);
// Size before position: shrinking first means the subsequent move
// has a frame that already fits, so it cannot be pushed back out.
// `clamp_to_work_area` only ever shrinks and shifts within this one
// monitor, so this cannot bounce the window onto another display and
// re-trigger the handler.
if (w, h) != (size.width, size.height) {
if let Err(err) = window.set_size(PhysicalSize::new(w, h)) {
log::warn!("[window-state] set_size during scale-change re-clamp failed: {err}");
}
}
if (x, y) != (pos.x, pos.y) {
if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) {
log::warn!("[window-state] set_position during scale-change re-clamp failed: {err}");
}
}
}
/// Subscribe the main window to scale-factor changes so it is re-fitted
/// whenever the OS moves it across a DPI boundary (#5041).
///
/// Call once, from `setup()`, alongside `restore_main` / `center_main`.
pub fn install_dpi_guard<R: Runtime>(window: &WebviewWindow<R>) {
let guarded = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::ScaleFactorChanged { scale_factor, .. } = event {
reclamp_after_scale_change(&guarded, *scale_factor);
}
});
log::info!("[window-state] DPI guard installed on main window");
}
/// Project a live `Monitor` into the plain-data [`WorkArea`] the pure
/// geometry helpers operate on. Single conversion point so the
/// physical-pixel contract is stated once.
fn work_area_of(monitor: &tauri::Monitor) -> WorkArea {
let wa = monitor.work_area();
WorkArea {
x: wa.position.x,
y: wa.position.y,
width: wa.size.width,
height: wa.size.height,
scale: monitor.scale_factor(),
}
}
fn collect_work_areas<R: Runtime>(window: &WebviewWindow<R>) -> Vec<WorkArea> {
let Ok(monitors) = window.available_monitors() else {
return Vec::new();
};
monitors
.iter()
.map(|m| {
let wa = m.work_area();
WorkArea {
x: wa.position.x,
y: wa.position.y,
width: wa.size.width,
height: wa.size.height,
}
})
.collect()
monitors.iter().map(work_area_of).collect()
}
fn primary_or_current_work_area<R: Runtime>(window: &WebviewWindow<R>) -> Option<WorkArea> {
@@ -291,13 +501,47 @@ fn primary_or_current_work_area<R: Runtime>(window: &WebviewWindow<R>) -> Option
.ok()
.flatten()
.or_else(|| window.current_monitor().ok().flatten())?;
let wa = monitor.work_area();
Some(WorkArea {
x: wa.position.x,
y: wa.position.y,
width: wa.size.width,
height: wa.size.height,
})
Some(work_area_of(&monitor))
}
/// Emit the full monitor layout once at startup.
///
/// Mixed-DPI multi-monitor bugs (#5041) are effectively undebuggable from
/// a user report without knowing each monitor's origin, work area and
/// scale factor — the reporter's own numbers came from a third-party
/// tool and could not be reconciled afterwards. Logged at info so it
/// lands in the shipped log file.
pub fn log_monitor_layout<R: Runtime>(window: &WebviewWindow<R>) {
let Ok(monitors) = window.available_monitors() else {
log::warn!("[window-state] available_monitors unavailable; cannot log layout");
return;
};
let primary_name = window
.primary_monitor()
.ok()
.flatten()
.and_then(|m| m.name().cloned());
log::info!(
"[window-state] monitor layout: {} attached, primary={:?}",
monitors.len(),
primary_name
);
for (idx, m) in monitors.iter().enumerate() {
let wa = m.work_area();
let size = m.size();
log::info!(
"[window-state] monitor[{}] name={:?} scale={} full={}x{} work_area=({},{} {}x{})",
idx,
m.name(),
m.scale_factor(),
size.width,
size.height,
wa.position.x,
wa.position.y,
wa.size.width,
wa.size.height,
);
}
}
/// Return the work area whose intersection with the saved window rect
@@ -339,6 +583,92 @@ fn pick_monitor_for_window(
.map(|(_, wa)| wa)
}
/// Pick the monitor a freshly-created window should be sized and centered
/// against.
///
/// Preference order:
/// 1. The monitor the window is **actually on** (largest work-area
/// overlap). Staying put avoids a cross-monitor move entirely, which
/// on Windows is what triggers the DPI rescale described in
/// [`dpi_adjusted_size`].
/// 2. The primary monitor, when the window overlaps nothing (Windows can
/// place a not-yet-shown window at `CW_USEDEFAULT`, off every work
/// area).
/// 3. Any attached monitor, so we never return `None` while monitors exist.
///
/// The previous behaviour — always prefer the primary — is what made the
/// mixed-DPI case (#5041) reachable: a window created on a 100 % monitor
/// was clamped against the 150 % primary's work area and then moved
/// there, and Windows rescaled the just-applied size by 1.5 on arrival.
fn target_work_area_for_new_window(
x: i32,
y: i32,
width: u32,
height: u32,
work_areas: &[WorkArea],
primary: Option<WorkArea>,
) -> Option<WorkArea> {
pick_monitor_for_window(x, y, width, height, work_areas)
.or(primary)
.or_else(|| work_areas.first().copied())
}
/// Predict the physical size an OS will impose on a window that moves
/// between monitors with different DPI scale factors.
///
/// This is the crux of #5041. Windows preserves a window's **logical**
/// size across a monitor change: on `WM_DPICHANGED` it multiplies the
/// physical size by `to_scale / from_scale`. So setting a physical size
/// that fits monitor B, and *then* moving the window from monitor A to
/// monitor B, does not leave the window at that size — it arrives
/// `to_scale / from_scale` times larger.
///
/// Concretely, for the reporter's layout (secondary 1920×1080 @ 100 %,
/// primary 2560×1600 @ 150 %): a window correctly clamped to the
/// primary's 2560-px width while still sitting on the secondary arrives
/// at `2560 × 1.5 = 3840` physical pixels — wider than the 2560-px
/// monitor it was just fitted to, overflowing off the right edge.
///
/// Returns the input unchanged when either scale is not a usable
/// positive, finite number, so a runtime reporting `0.0` or `NaN` can
/// never zero out or panic the window size.
fn dpi_adjusted_size(width: u32, height: u32, from_scale: f64, to_scale: f64) -> (u32, u32) {
let usable = |s: f64| s.is_finite() && s > 0.0;
if !usable(from_scale) || !usable(to_scale) {
return (width, height);
}
let ratio = to_scale / from_scale;
if !ratio.is_finite() || ratio <= 0.0 {
return (width, height);
}
let scaled = |v: u32| {
// Round rather than truncate. Truncation always loses, so a
// window shuttled between two monitors shrinks by up to a pixel
// per move and the error accumulates without bound. Rounding
// keeps it bounded at ±1 px total, however many moves happen.
//
// It is NOT an exact inverse in general. When the first hop is a
// *downscale* the intermediate size has genuinely lost
// information, and scaling back up cannot recover it: 1281 px at
// 2.0→1.25 gives 801, and 801 back at 1.25→2.0 gives 1282. The
// round trip is exact when the first hop scales up (the common
// case: a window sized on a low-DPI monitor moving to a high-DPI
// one and back). `dpi_adjusted_size_round_trips_without_drift`
// pins both halves of that contract.
let out = (f64::from(v) * ratio).round();
// Saturate instead of wrapping — an absurd ratio must not
// produce a tiny window via u32 overflow.
if out >= f64::from(u32::MAX) {
u32::MAX
} else if out <= 0.0 {
1
} else {
out as u32
}
};
(scaled(width), scaled(height))
}
/// Clamp width/height into the work area while preserving the
/// `MIN_WINDOW_*` floors. Pure helper extracted from
/// [`clamp_to_work_area`] so `center_main` can reuse it when the window
@@ -401,14 +731,31 @@ mod tests {
}
fn wa(x: i32, y: i32, width: u32, height: u32) -> WorkArea {
wa_dpi(x, y, width, height, 1.0)
}
/// Same as [`wa`] but with an explicit DPI scale factor, for the
/// mixed-DPI cases in #5041.
fn wa_dpi(x: i32, y: i32, width: u32, height: u32, scale: f64) -> WorkArea {
WorkArea {
x,
y,
width,
height,
scale,
}
}
/// The reporter's layout from #5041: a 2560x1600 @ 150 % primary at
/// the virtual-desktop origin, and a 1920x1080 @ 100 % secondary
/// placed to its **left** (so the secondary's origin is negative).
/// Work areas subtract a plausible taskbar.
fn reporter_layout() -> (WorkArea, WorkArea) {
let primary = wa_dpi(0, 0, 2560, 1520, 1.5);
let secondary = wa_dpi(-1920, 0, 1920, 1032, 1.0);
(primary, secondary)
}
#[test]
fn clamp_leaves_in_bounds_geometry_alone() {
// 1280×800 work area, 1000×800 window centered-ish: width fits,
@@ -536,6 +883,211 @@ mod tests {
assert_eq!((m.x, m.width), (1920, 1280));
}
// ── Mixed-DPI multi-monitor (#5041) ──────────────────────────────
#[test]
fn dpi_adjusted_size_grows_when_moving_to_a_higher_dpi_monitor() {
// The #5041 arithmetic: a window fitted to the 150 % primary's
// 2560 px work-area width while still sitting on the 100 %
// secondary arrives at 3840 px — wider than the very monitor it
// was just fitted to. This is the reported ~3875 px window.
let (grown_w, grown_h) = dpi_adjusted_size(2560, 1520, 1.0, 1.5);
assert_eq!(grown_w, 3840);
assert_eq!(grown_h, 2280);
}
#[test]
fn dpi_adjusted_size_shrinks_when_moving_to_a_lower_dpi_monitor() {
let (w, h) = dpi_adjusted_size(3840, 2280, 1.5, 1.0);
assert_eq!((w, h), (2560, 1520));
}
#[test]
fn dpi_adjusted_size_is_identity_at_equal_scale() {
assert_eq!(dpi_adjusted_size(1280, 900, 1.5, 1.5), (1280, 900));
assert_eq!(dpi_adjusted_size(1280, 900, 1.0, 1.0), (1280, 900));
}
#[test]
fn dpi_adjusted_size_round_trips_without_drift() {
// A→B→A must land back on the original size; truncating instead
// of rounding would lose a pixel on every move.
//
// Covers the whole standard DPI ladder rather than just 1.0↔1.5:
// a single pair does not establish the property (review, #5041).
// Odd dimensions are deliberate — even ones round-trip trivially.
const SIZES: [(u32, u32); 3] = [(1281, 901), (1920, 1080), (2560, 1600)];
// Upscale first: exact. This is the common case — a window sized
// on a low-DPI monitor moves to a high-DPI one and back.
for (from, to) in [
(1.0, 1.25),
(1.0, 1.5),
(1.0, 1.75),
(1.0, 2.0),
(1.25, 1.5),
(1.5, 2.0),
] {
for (w, h) in SIZES {
let (up_w, up_h) = dpi_adjusted_size(w, h, from, to);
let (back_w, back_h) = dpi_adjusted_size(up_w, up_h, to, from);
assert_eq!(
(back_w, back_h),
(w, h),
"upscale-first round trip {w}x{h} at {from}->{to}->{from} drifted to {back_w}x{back_h}"
);
}
}
// Downscale first: bounded, not exact. The intermediate size has
// genuinely lost information (1281 at 2.0->1.25 is 801, and 801
// back up is 1282), so the contract is that the error stays
// within 1 px and never accumulates — which is precisely what
// truncation fails to do.
for (from, to) in [(2.0, 1.25), (1.75, 1.0), (2.0, 1.0), (1.5, 1.25)] {
for (w, h) in SIZES {
let (down_w, down_h) = dpi_adjusted_size(w, h, from, to);
let (back_w, back_h) = dpi_adjusted_size(down_w, down_h, to, from);
assert!(
back_w.abs_diff(w) <= 1 && back_h.abs_diff(h) <= 1,
"downscale-first round trip {w}x{h} at {from}->{to}->{from} drifted to {back_w}x{back_h}, beyond the 1px bound"
);
}
}
}
#[test]
fn dpi_adjusted_size_rejects_unusable_scale_factors() {
// A runtime reporting 0.0 / NaN / infinity must not zero out or
// panic the window size — return the input untouched instead.
for (from, to) in [
(0.0, 1.5),
(1.5, 0.0),
(f64::NAN, 1.5),
(1.5, f64::NAN),
(f64::INFINITY, 1.5),
(1.5, f64::INFINITY),
(-1.0, 1.5),
] {
assert_eq!(
dpi_adjusted_size(1280, 900, from, to),
(1280, 900),
"scale pair ({from}, {to}) must be treated as unusable"
);
}
}
#[test]
fn target_prefers_the_monitor_the_window_is_already_on() {
// Window created on the 100 % secondary (negative origin).
// Selecting the primary here is what forces the cross-DPI move
// that #5041 is about — we must stay put instead.
let (primary, secondary) = reporter_layout();
let target = target_work_area_for_new_window(
-1800,
100,
1280,
900,
&[primary, secondary],
Some(primary),
)
.expect("a monitor should be selected");
assert_eq!(
(target.x, target.width),
(secondary.x, secondary.width),
"window on the secondary must be sized against the secondary"
);
assert!(
(target.scale - secondary.scale).abs() < f64::EPSILON,
"and must carry the secondary's scale factor"
);
}
#[test]
fn target_falls_back_to_primary_when_window_overlaps_nothing() {
// Windows can place a not-yet-shown window at CW_USEDEFAULT,
// off every work area.
let (primary, secondary) = reporter_layout();
let target = target_work_area_for_new_window(
50_000,
50_000,
1280,
900,
&[primary, secondary],
Some(primary),
)
.expect("should fall back to primary");
assert_eq!((target.x, target.width), (primary.x, primary.width));
}
#[test]
fn target_falls_back_to_any_monitor_without_a_primary() {
let (primary, secondary) = reporter_layout();
let target =
target_work_area_for_new_window(50_000, 50_000, 1280, 900, &[secondary, primary], None)
.expect("should fall back to the first attached monitor");
assert_eq!(target.x, secondary.x);
}
#[test]
fn target_returns_none_without_monitors() {
assert!(target_work_area_for_new_window(0, 0, 1280, 900, &[], None).is_none());
}
#[test]
fn clamp_then_move_overflows_but_move_then_clamp_fits() {
// End-to-end regression for #5041, expressed against the pure
// helpers so it runs without a Tauri runtime.
//
// Setup: an oversized window (larger than either work area) is
// created on the 100 % secondary and needs to end up on the
// 150 % primary.
let (primary, _secondary) = reporter_layout();
let (created_w, created_h) = (3000, 1700);
// OLD ordering — clamp to the destination, then move. The OS
// rescales by 1.5 on arrival and the result overflows the very
// monitor it was fitted to.
let (clamped_w, clamped_h) = clamp_size(created_w, created_h, &primary);
assert_eq!(
(clamped_w, clamped_h),
(2560, 1520),
"clamp itself is correct"
);
let (arrived_w, arrived_h) = dpi_adjusted_size(clamped_w, clamped_h, 1.0, 1.5);
assert!(
arrived_w > primary.width && arrived_h > primary.height,
"old ordering must reproduce the oversized window: {arrived_w}x{arrived_h} vs work area {}x{}",
primary.width,
primary.height
);
// NEW ordering — move first (absorbing the OS rescale), then
// clamp against the destination. Result fits.
let (moved_w, moved_h) = dpi_adjusted_size(created_w, created_h, 1.0, 1.5);
let (final_w, final_h) = clamp_size(moved_w, moved_h, &primary);
assert!(
final_w <= primary.width && final_h <= primary.height,
"new ordering must fit the work area: {final_w}x{final_h} vs {}x{}",
primary.width,
primary.height
);
}
#[test]
fn reclamped_geometry_never_leaves_the_work_area_after_a_scale_change() {
// What `reclamp_after_scale_change` delegates to: whatever size
// the OS imposed on arrival, the window must end up wholly
// inside the destination work area.
let (primary, _) = reporter_layout();
let (os_w, os_h) = dpi_adjusted_size(2560, 1520, 1.0, 1.5);
let (x, y, w, h) = clamp_to_work_area(0, 0, os_w, os_h, primary);
assert!(w <= primary.width && h <= primary.height);
assert!(x >= primary.x && y >= primary.y);
assert!(x.saturating_add(w as i32) <= primary.x.saturating_add(primary.width as i32));
assert!(y.saturating_add(h as i32) <= primary.y.saturating_add(primary.height as i32));
}
#[test]
fn center_origin_after_min_floor_stays_in_work_area() {
// Repro for the `center_main` edge case: a pathological work