Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop-tauri/src-tauri/src/shell/position.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ pub fn remember_current_geometry_if_eligible(window: &tauri::Window) {
};
let current_mode = {
let guard = st.lock().unwrap();
if guard.geometry_capture_suppressed(std::time::Instant::now()) {
// A programmatic layout (open/transition) is still applying
// size/position; its resize/move events must not be persisted as a
// user drag (SOU-222).
return;
}
guard.surface_machine.current()
};
// The TrayPanel flyout persists its size explicitly from the frontend (only
Expand Down
93 changes: 89 additions & 4 deletions apps/desktop-tauri/src-tauri/src/shell/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,24 @@ pub fn apply_window_layout(
// falls back to the mode's default size for non-remembered modes.
let (width, height) =
logical_size_from_geometry(mode, props, crate::geometry_store::load(mode));
let (width, height) = capped_logical_size(window, width, height);
let (width, height) =
capped_logical_size(window, width, height, props.min_width, props.min_height);
let size = tauri::LogicalSize::new(width, height);

// Arm geometry-capture suppression immediately before the programmatic
// set_size (and the transition's subsequent position apply) so their OS
// resize/move events are not persisted as if the user had dragged the
// window (SOU-222). Armed here — after the geometry load/cap above — so
// the 750 ms window starts at the actual mutation, not before slow prep,
// and set_size can't run after the guard has already expired.
if let Some(st) = window.app_handle().try_state::<Mutex<AppState>>()
&& let Ok(mut guard) = st.lock()
{
guard.arm_geometry_capture_suppression(
std::time::Instant::now() + std::time::Duration::from_millis(750),
);
}

window.set_size(size).map_err(map_err)?;

if let (Some(min_w), Some(min_h)) = (props.min_width, props.min_height) {
Expand Down Expand Up @@ -133,7 +149,13 @@ pub(super) fn logical_size_from_geometry(
)
}

fn capped_logical_size(window: &WebviewWindow, width: f64, height: f64) -> (f64, f64) {
fn capped_logical_size(
window: &WebviewWindow,
width: f64,
height: f64,
min_width: Option<f64>,
min_height: Option<f64>,
) -> (f64, f64) {
const MARGIN: f64 = 16.0;

let Some(monitor) = window
Expand All @@ -152,9 +174,31 @@ fn capped_logical_size(window: &WebviewWindow, width: f64, height: f64) -> (f64,
1.0
};
let work_area = monitor.work_area();
let max_width = (work_area.size.width as f64 / scale - MARGIN).max(320.0);
let max_height = (work_area.size.height as f64 / scale - MARGIN).max(240.0);
cap_to_available(
width,
height,
work_area.size.width as f64 / scale - MARGIN,
work_area.size.height as f64 / scale - MARGIN,
min_width,
min_height,
)
}

/// Cap a logical size to the available work area, but never below the window's
/// own minimum. Capping below the minimum makes `set_size` request a
/// sub-minimum size that the OS clamps back up to the minimum — and that clamp
/// used to be persisted as the remembered size, collapsing the window
/// permanently (SOU-222). Modes without a minimum keep a small 320x240 floor.
fn cap_to_available(
width: f64,
height: f64,
available_width: f64,
available_height: f64,
min_width: Option<f64>,
min_height: Option<f64>,
) -> (f64, f64) {
let max_width = available_width.max(min_width.unwrap_or(320.0));
let max_height = available_height.max(min_height.unwrap_or(240.0));
(width.min(max_width), height.min(max_height))
}

Expand Down Expand Up @@ -273,3 +317,44 @@ where
target: state.current_target.clone(),
})
}

#[cfg(test)]
mod tests {
use super::cap_to_available;

#[test]
fn cap_never_shrinks_below_minimum() {
// A transient tiny work-area read must not cap a 720x560 window below
// its 520x400 minimum (SOU-222). The cap floors to the minimum, so
// set_size never requests a sub-minimum size the OS clamps + persists.
assert_eq!(
cap_to_available(720.0, 560.0, 100.0, 80.0, Some(520.0), Some(400.0)),
(520.0, 400.0)
);
}

#[test]
fn cap_applies_available_when_between_min_and_requested() {
assert_eq!(
cap_to_available(720.0, 560.0, 640.0, 500.0, Some(520.0), Some(400.0)),
(640.0, 500.0)
);
}

#[test]
fn cap_keeps_requested_size_when_it_fits() {
assert_eq!(
cap_to_available(720.0, 560.0, 1904.0, 1040.0, Some(520.0), Some(400.0)),
(720.0, 560.0)
);
}

#[test]
fn cap_uses_default_floor_for_min_less_modes() {
// Modes without an explicit minimum keep the 320x240 baseline floor.
assert_eq!(
cap_to_available(1000.0, 800.0, 50.0, 50.0, None, None),
(320.0, 240.0)
);
}
}
35 changes: 35 additions & 0 deletions apps/desktop-tauri/src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ pub struct AppState {
/// One-shot grace for a blur event caused while revealing the tray panel
/// during explicit startup.
pub startup_tray_blur_grace_until: Option<std::time::Instant>,
/// Set while the app is programmatically sizing/positioning a window, so
/// the OS resize/move events that follow are not captured as if the user
/// had dragged the window (SOU-222: stopped the PopOut from persisting a
/// clamped-to-minimum size and reopening tiny forever).
pub suppress_geometry_capture_until: Option<std::time::Instant>,
/// Whether the explicit startup path may use its delayed shell fallback.
pub startup_tray_reveal_pending: bool,
/// One-shot permission for frontend layout code to reveal a newly opened flyout.
Expand Down Expand Up @@ -194,6 +199,7 @@ impl AppState {
capacity_event_observer: crate::capacity_events::CapacityEventObserver::load_default(),
last_shown_at: None,
startup_tray_blur_grace_until: None,
suppress_geometry_capture_until: None,
startup_tray_reveal_pending: false,
flyout_reveal_pending: false,
gesture_blur_guard: None,
Expand Down Expand Up @@ -226,6 +232,19 @@ impl AppState {
.is_some_and(|until| now <= until)
}

/// Suppress automatic geometry capture until `until`. Armed right before
/// the app programmatically resizes/moves a window.
pub fn arm_geometry_capture_suppression(&mut self, until: std::time::Instant) {
self.suppress_geometry_capture_until = Some(until);
}

/// Whether a programmatic layout is still in its suppression window, so the
/// current resize/move event should not be persisted as user geometry.
pub fn geometry_capture_suppressed(&self, now: std::time::Instant) -> bool {
self.suppress_geometry_capture_until
.is_some_and(|until| now <= until)
}

pub fn take_startup_tray_reveal_fallback(&mut self) -> bool {
std::mem::take(&mut self.startup_tray_reveal_pending)
}
Expand Down Expand Up @@ -352,6 +371,22 @@ mod tests {
assert_eq!(state.current_target, SurfaceTarget::Summary);
}

#[test]
fn geometry_capture_suppression_expires() {
use std::time::{Duration, Instant};
let mut state = AppState::new();
let now = Instant::now();
// Nothing armed -> capture is allowed.
assert!(!state.geometry_capture_suppressed(now));

let until = now + Duration::from_millis(750);
state.arm_geometry_capture_suppression(until);
assert!(state.geometry_capture_suppressed(now));
assert!(state.geometry_capture_suppressed(until));
// Past the window, capture resumes (a genuine user drag is persisted).
assert!(!state.geometry_capture_suppressed(until + Duration::from_millis(1)));
}

#[test]
fn transition_applies_dashboard_target_for_pop_out() {
let mut state = AppState::new();
Expand Down
Loading