From 7010c5e93e54cdc24fc068b4d574e2dfcbe104e6 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:06:36 -0400 Subject: [PATCH] docking: a minimized native window shrinks to its bar (#320) * docking: a minimized native window shrinks to its bar A floating window shown in its own OS window kept its full size when minimized, with an empty body under the tab strip. Minimizing now sizes the OS window to its bar and the tab strip of its only group, at the same width and top-left corner, and fixes its height with the window's min and max inner size; expanding restores the saved size. The layout's native and in-window rects keep the expanded height: the collapsed height the window reports is not recorded, its position and width are. * docking: a minimized native window's limits change before its size, and follow its bar height --- crates/ui/src/docking/native_tests.rs | 56 ++++++++++++++++++++++++ crates/ui/src/docking/render/floating.rs | 12 +++-- crates/ui/src/docking/render/native.rs | 27 ++++++++++-- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 63bd9f9e..98a53295 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -328,6 +328,62 @@ fn a_restored_window_opens_on_a_connected_monitor() { assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); } +/// The last `InnerSize`, `MinInnerSize` and `MaxInnerSize` sent to `viewport`. +fn size_commands(d: &DockDriver, viewport: egui::ViewportId) -> [Option; 3] { + let mut sizes = [None; 3]; + for (_, c) in d.host().commands.iter().filter(|(v, _)| *v == viewport) { + match c { + ViewportCommand::InnerSize(s) => sizes[0] = Some(*s), + ViewportCommand::MinInnerSize(s) => sizes[1] = Some(*s), + ViewportCommand::MaxInnerSize(s) => sizes[2] = Some(*s), + _ => {} + } + } + sizes +} + +#[test] +fn a_minimized_native_window_shrinks_to_its_bar_and_keeps_its_expanded_size() { + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + let open = os_window(&d, 5).unwrap().0; + let (native, rect) = (window(&d, 5).native, window(&d, 5).rect); + assert_eq!(native, Some([open.left(), open.top(), 240.0, 300.0])); + + // Its bar and its group's tab strip, at the same width and top-left corner; only the width + // resizes. + let collapsed = BAR + super::DockStyle::default().tab_height; + d.layout.apply(Action::SetMinimized { panel: 5, minimized: true }).unwrap(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0, Rect::from_min_size(open.min, vec2(240.0, collapsed))); + let [_, min, max] = size_commands(&d, viewport); + assert_eq!((min.map(|s| s.y), max.map(|s| s.y)), (Some(collapsed), Some(collapsed))); + + // The layout keeps the expanded size after the window reports the collapsed one, past the + // wait for commanded moves, and takes a new width. + for _ in 0..40 { + d.step(vec![]); + } + assert_eq!((window(&d, 5).native, window(&d, 5).rect), (native, rect)); + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(open.min, vec2(300.0, collapsed)); + for _ in 0..40 { + d.step(vec![]); + } + assert_eq!(window(&d, 5).native, Some([open.left(), open.top(), 300.0, 300.0])); + assert_eq!(window(&d, 5).rect[2..], [300.0, 300.0]); + + // Expanded: the saved height comes back, and the height resizes again. + d.layout.apply(Action::SetMinimized { panel: 5, minimized: false }).unwrap(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0, Rect::from_min_size(open.min, vec2(300.0, 300.0))); + assert_eq!(size_commands(&d, viewport), [Some(vec2(300.0, 300.0)), Some(vec2(160.0, 100.0)), Some(egui::Vec2::INFINITY)]); + // The limits change before the size: the OS clamps a new size to the limits it has. + let commands: Vec<_> = d.host().commands.iter().filter(|(v, _)| *v == viewport).map(|(_, c)| c.clone()).collect(); + let last = |f: fn(&ViewportCommand) -> bool| commands.iter().rposition(f); + assert!(last(|c| matches!(c, ViewportCommand::MaxInnerSize(_))) < last(|c| matches!(c, ViewportCommand::InnerSize(_))), "{commands:?}"); + assert_eq!(window(&d, 5).native, Some([open.left(), open.top(), 300.0, 300.0])); +} + #[test] fn a_locked_area_moves_no_native_window_and_records_where_the_os_put_it() { let mut d = driver(layout(true)); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index fded5aff..ceab8df8 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -39,15 +39,19 @@ impl Renderer<'_, P> { pub(super) fn window_limits(&self, window: &Floating

) -> PanelLimits { let chrome = self.window_chrome(window); let tree = node_limits(&window.root, self.style, &*self.content); - let body = match (&window.root, window.minimized) { - (Node::Tabs { .. }, true) => (self.style.tab_height, self.style.tab_height), - (_, true) => (0.0, 0.0), - (_, false) => (tree.min.y, tree.max.y), + let body = match window.minimized { + true => (self.minimized_body(window), self.minimized_body(window)), + false => (tree.min.y, tree.max.y), }; let min = vec2(tree.min.x, chrome + body.0).min(vec2(1_000_000.0, 1_000_000.0)); PanelLimits { min, max: vec2(tree.max.x, chrome + body.1).max(min).min(vec2(1_000_000.0, 1_000_000.0)) } } + /// The height of a minimized window's body: the tab strip of its only group, or nothing. + pub(super) fn minimized_body(&self, window: &Floating

) -> f32 { + if matches!(window.root, Node::Tabs { .. }) { self.style.tab_height } else { 0.0 } + } + /// The title bar and, for a movable (resizable) window, the resize band above it. fn window_chrome(&self, window: &Floating

) -> f32 { let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 1dd96e49..06839e87 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -164,6 +164,9 @@ struct Place { corrected: bool, /// Shown, as it opened or was last told. shown: bool, + /// The height of its bar while minimized, as it was last told. `known` keeps the expanded + /// size meanwhile. + collapsed: Option, } /// Whether two rects differ by more than rounding. @@ -448,6 +451,9 @@ impl Renderer<'_, P> { let moving = |ctx: &Context| ctx.data(|d| d.get_temp::(drag_key)).is_some_and(|d| d.window == window.id); let info = ctx.input(|i| i.raw.viewports.get(&viewport).cloned()); let frame = info.as_ref().and_then(content_of).map_or(Vec2::ZERO, |(_, frame)| frame); + // A minimized window shows its bar and what `floating_body` draws below it. + let collapsed = window.minimized.then(|| BAR_HEIGHT + self.minimized_body(window)); + let shown = |[x, y, w, h]: [f32; 4]| [x, y, w, collapsed.unwrap_or(h)]; let mut place = match ctx.data(|d| d.get_temp::(key)) { Some(mut place) => { // The application changed the window's place (undo, reset, a reloaded layout): @@ -456,7 +462,7 @@ impl Renderer<'_, P> { && let Some(native) = window.native.filter(|native| differs(*native, place.known)) { let target = kept_reachable(native, &self.monitors); - place_window(ctx, viewport, target, frame); + place_window(ctx, viewport, shown(target), frame); (place.known, place.pending) = (target, Some(now + MOVE_WAIT)); } place @@ -474,9 +480,22 @@ impl Renderer<'_, P> { }; seen.insert(window.id); ctx.data_mut(|d| d.insert_temp(seen_key, seen)); - Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, shown: true } + Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, shown: true, collapsed: None } } }; + // Minimized or expanded: the window keeps its top-left corner and width, and its + // height is fixed while it shows only its bar. + // The limits go first: the OS clamps the new size to the limits it has. + if place.collapsed != collapsed { + let (min, max) = match collapsed { + Some(height) => (vec2(MIN_SIZE[0], height), vec2(1_000_000.0, height)), + None => (Vec2::from(MIN_SIZE), Vec2::INFINITY), + }; + ctx.send_viewport_cmd_to(viewport, ViewportCommand::MinInnerSize(min)); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::MaxInnerSize(max)); + place_window(ctx, viewport, shown(place.known), frame); + (place.collapsed, place.pending) = (collapsed, Some(now + MOVE_WAIT)); + } let spawn = place.spawn; let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); let builder = ViewportBuilder::default() @@ -599,7 +618,9 @@ impl Renderer<'_, P> { let opacity = if this { drag_opacity(drag.as_ref(), float_only, self.style.title_bar_docks) } else { 1.0 }; set_window_opacity(&title, main.as_deref(), opacity); } - if let Some(placed) = report.placed.filter(|_| !moving) { + // The collapsed height a minimized window reports is not the size to keep. + let reported = report.placed.map(|[x, y, w, h]| [x, y, w, if place.collapsed.is_some() { place.known[3] } else { h }]); + if let Some(placed) = reported.filter(|_| !moving) { let waiting = place.pending.is_some_and(|until| now < until); if !differs(placed, place.known) { place.pending = None;