diff --git a/crates/ui/README.md b/crates/ui/README.md index 16bb8d95..888828db 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -13,6 +13,12 @@ and command handling remain in each application. No icons, fonts or application ## Host-owned docking chrome +Iconic flyouts resize from their canvas-side edge, bottom and shared corner, keeping +the edge against the icon column anchored. Resizing saves the group's `height` +and `flyout_width`, so workspaces retain independent sizes for each group. +An explicit height overrides content fitting; untouched heights continue to fit +content, including after a width-only drag. Layout locks and float bounds apply. + `DockContent` can replace an iconic column or a complete tab group without a second layout tree. `column_rect` reports the fitted column before painting; `owns_iconic_column`/`iconic_column` and `custom_group` suppress the corresponding diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 3841c256..265e768c 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -117,6 +117,7 @@ pub struct FloatingFrame<'a> { limits: PanelLimits, resizable: bool, resize_axes: [bool; 2], + resize_edges: [bool; 4], resize_label: &'a str, opacity: f32, reserve_top_resize_band: bool, @@ -146,6 +147,7 @@ impl<'a> FloatingFrame<'a> { limits: PanelLimits::default(), resizable: true, resize_axes: [true, true], + resize_edges: [true; 4], resize_label: "Resize window", opacity: 1.0, reserve_top_resize_band: true, @@ -159,13 +161,14 @@ impl<'a> FloatingFrame<'a> { } /// Size limits of a resize, top resize band included. The frame itself is drawn at the - /// given rect. + /// given rect. Minimums are capped by the bounds when the viewport cannot fit them. pub fn limits(mut self, limits: PanelLimits) -> Self { self.limits = limits; self } /// Reserve a separate top resize gutter; disable when contents already supply a header. + /// Hosts supplying their own header also supply its minimum size through [`Self::limits`]. pub fn reserve_top_resize_band(mut self, reserve: bool) -> Self { self.reserve_top_resize_band = reserve; self @@ -182,6 +185,13 @@ impl<'a> FloatingFrame<'a> { self } + /// Enable the left, right, top and bottom edges. A corner requires both its edges. + /// Hosts anchored to a dock can expose only the edges facing away from it. + pub fn resize_edges(mut self, edges: [bool; 4]) -> Self { + self.resize_edges = edges; + self + } + /// Accessibility label of the resize handles. pub fn resize_label(mut self, label: &'a str) -> Self { self.resize_label = label; @@ -242,9 +252,20 @@ impl<'a> FloatingFrame<'a> { let mut resized = None; let mut started = None; let mut responses = Vec::new(); - let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; + // Only stock chrome needs its title-bar minimum. Host-owned headers use the host's + // limits; retain a nonempty floor that cannot exceed an already-constrained rect. + let chrome_min = if self.reserve_top_resize_band { vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0) } else { Vec2::splat(1.0).min(rect.size()) }; + // Bounds may force one axis below its minimum; do not let that block the other axis. + let limits = PanelLimits { min: self.limits.min.max(chrome_min).min(self.limits.max).min(bounds.size()), ..self.limits }; let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx()); for (name, mut handle, mut direction) in handles(rect) { + if (direction.x < 0.0 && !self.resize_edges[0]) + || (direction.x > 0.0 && !self.resize_edges[1]) + || (direction.y < 0.0 && !self.resize_edges[2]) + || (direction.y > 0.0 && !self.resize_edges[3]) + { + continue; + } if !self.reserve_top_resize_band && direction.y < 0.0 { handle.max.y = (rect.top() + ui.ctx().pixels_per_point().recip()).min(rect.bottom()); } @@ -525,6 +546,67 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt mod compatibility_tests { use super::*; + /// Viewport clipping may force one stock-chrome axis below its normal minimum. + /// The other axis still resizes while keeping feasible host/chrome limits and its anchor. + #[test] + fn constrained_stock_frames_keep_the_other_axis_resizable() { + use egui::{Event, PointerButton, RawInput}; + for (size, handle, delta, stock_minimum, host_minimum) in [ + (vec2(30.0, 300.0), "resize-bottom", vec2(0.0, 20.0), vec2(30.0, 38.0), vec2(30.0, 90.0)), + (vec2(300.0, 30.0), "resize-right", vec2(20.0, 0.0), vec2(40.0, 30.0), vec2(80.0, 30.0)), + ] { + for (minimum, expected_minimum) in [(vec2(20.0, 20.0), stock_minimum), (vec2(80.0, 90.0), host_minimum)] { + let ctx = Context::default(); + let id = Id::unique("constrained-stock-frame"); + let bounds = Rect::from_min_size(Pos2::ZERO, size); + let mut rect = Rect::from_min_size(pos2(10.0, 10.0), vec2(100.0, 100.0)); + let mut time = 0.0; + let mut frame = |events| { + time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 300.0))), + events, + time: Some(time), + ..Default::default() + }; + let mut next = rect; + let mut output = ctx.run_ui(input, |ui| { + let shown = FloatingFrame::new(id, rect).bounds(bounds).limits(PanelLimits { min: minimum, max: Vec2::splat(500.0) }).show( + ui.ctx(), + &ChromeStyle::from_ui(ui), + |_, _| {}, + ); + next = shown.resized.unwrap_or(shown.rect); + }); + output.textures_delta.clear(); + rect = next; + next + }; + let drag = |frame: &mut dyn FnMut(Vec) -> Rect, from, to| { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![Event::PointerMoved(from), button(from, true)]); + frame(vec![Event::PointerMoved(to)]); + frame(vec![button(to, false)]); + frame(vec![]) + }; + frame(vec![]); + let before = frame(vec![]); + let from = ctx.read_response(id.with(handle)).unwrap().rect.center(); + let grown = drag(&mut frame, from, from + delta); + assert_eq!(grown.size(), before.size() + delta, "bounds={size:?}, minimum={minimum:?}"); + assert_eq!(grown.min, before.min); + let from = ctx.read_response(id.with(handle)).unwrap().rect.center(); + let small = drag(&mut frame, from, from - delta * 100.0); + assert_eq!(small.size(), expected_minimum); + assert_eq!(small.min, before.min); + let from = ctx.read_response(id.with(handle)).unwrap().rect.center(); + let large = drag(&mut frame, from, from + delta * 100.0); + assert!(bounds.contains_rect(large)); + assert_eq!(large.min, before.min); + } + } + } + #[test] fn defaults_preserve_pre_host_hook_frame_geometry() { let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 12e8468d..0b477d68 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -40,11 +40,14 @@ pub struct Group

{ /// [`DockContent::dock_heights`](crate::docking::DockContent::dock_heights). #[cfg_attr(feature = "serde", serde(default))] pub height: Option, + /// User-set flyout width; absent in older workspaces, which use the column width. + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + pub flyout_width: Option, } impl

Group

{ pub fn new(panels: Vec

, active: usize) -> Self { - Self { panels, active, minimized: false, height: None } + Self { panels, active, minimized: false, height: None, flyout_width: None } } pub fn active_panel(&self) -> Option<&P> { @@ -134,6 +137,8 @@ pub enum PanelState { pub struct GroupLocation { pub minimized: bool, pub height: Option, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + pub flyout_width: Option, /// The column, when the group was docked. pub column: Option, } @@ -698,6 +703,8 @@ impl Layout

{ } } + /// Detach the panel's group, preserving dock dimensions and floating-window geometry. + /// A stack entry becomes a single-panel group with its saved height. fn take_group(&mut self, panel: &P) -> Result, DockError> { let place = self.place(panel).ok_or(DockError::MissingPanel)?; match place { @@ -718,7 +725,7 @@ impl Layout

{ && let Some(index) = entries.iter().position(|e| &e.panel == panel) { let entry = entries.remove(index); - let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }; + let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height, flyout_width: None }; let moving = whole(Moving::group(group)); return Ok(Moving { rect: None, native: None, id: None, ..moving }); } @@ -726,7 +733,13 @@ impl Layout

{ Some(Node::Tabs { panels, active }) => Ok(whole(Moving::group(Group::new(panels, active)))), Some(Node::Stack { mut entries }) if entries.len() == 1 => { let entry = entries.remove(0); - Ok(whole(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }))) + Ok(whole(Moving::group(Group { + panels: vec![entry.panel], + active: 0, + minimized: !entry.open, + height: entry.height, + flyout_width: None, + }))) } _ => Err(DockError::MissingPanel), } diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 00926c98..635dd2fe 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -70,6 +70,244 @@ fn texts(d: &Driver) -> Vec { const CANVAS: Pos2 = pos2(600.0, 400.0); +/// The current flyout rectangle recorded in egui's area memory. +fn flyout_rect>(d: &DockDriver) -> Rect { + d.ctx.memory(|m| m.area_rect(d.id.with("flyout"))).unwrap() +} + +/// Build two iconic groups on either side of the canvas for anchored-resize tests. +fn iconic_on_side(right: bool) -> Layout { + let mut layout = docked(&[&[1, 2], &[3]], true); + if right { + let Some(Node::Split { first, second, size, .. }) = &mut layout.root else { panic!("split") }; + std::mem::swap(first, second); + *size = SplitSize::FixedSecond(240.0); + let Node::Dock { side, .. } = second.as_mut() else { panic!("dock") }; + *side = Side::End; + } + layout +} + +/// Flyouts expose only the canvas edge, bottom, and their shared corner on either dock side. +#[test] +fn flyout_free_edges_resize_and_keep_the_column_anchor() { + for right in [false, true] { + let mut d = DockDriver::new(iconic_on_side(right), Content); + d.click(hit(&d, Hit::Icon(1)).center()); + d.settle(); + let id = d.id.with("flyout"); + let enabled = if right { ["resize-left", "resize-bottom", "resize-bottom-left"] } else { ["resize-right", "resize-bottom", "resize"] }; + for name in chrome::RESIZE_HANDLES { + assert_eq!(d.ctx.read_response(id.with(name)).is_some(), enabled.contains(&name), "{name}, right={right}"); + } + d.ctx.memory_mut(|m| m.request_focus(id.with("resize-bottom"))); + assert!(DockArea::::new(d.id).chrome_has_focus(&d.ctx, &d.layout)); + let before = flyout_rect(&d); + let from = if right { before.left_bottom() + vec2(2.0, -2.0) } else { before.right_bottom() - vec2(2.0, 2.0) }; + d.drag(from, from + vec2(if right { -90.0 } else { 90.0 }, 100.0)); + d.settle(); + let after = flyout_rect(&d); + assert!((after.width() - before.width() - 90.0).abs() < 1.0, "{before:?} -> {after:?}"); + assert!((after.height() - before.height() - 100.0).abs() < 1.0, "{before:?} -> {after:?}"); + assert_eq!(after.top(), before.top()); + assert_eq!(if right { after.right() } else { after.left() }, if right { before.right() } else { before.left() }); + assert_eq!(open(&d), Some(1)); + assert!(d.layout.floating.is_empty()); + } +} + +/// User heights override both a host floating-size hint and the last short content height; +/// group height and width survive tab switches, closing, and a workspace round trip. +#[test] +fn resized_flyout_survives_reopening_and_workspace_restore() { + struct Short; + impl DockContent for Short { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Short"); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn floating_size(&self, _: &u32) -> Option { + Some(vec2(200.0, 160.0)) + } + } + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Short); + let icon = d.hit(&Hit::Icon(1)).unwrap().center(); + d.click(icon); + d.settle(); + let before = flyout_rect(&d); + assert!(before.height() <= 160.0); + let from = before.right_bottom() - vec2(2.0, 2.0); + d.drag(from, from + vec2(80.0, 170.0)); + d.settle(); + let grown = flyout_rect(&d); + assert!(grown.height() > 160.0); + assert_eq!(first_column(&d.layout).groups[0].height, Some(grown.height())); + assert_eq!(first_column(&d.layout).groups[0].flyout_width, Some(grown.width())); + assert_eq!(first_column(&d.layout).width, 240.0); + assert_eq!(first_column(&d.layout).groups[1].flyout_width, None); + assert_eq!(first_column(&d.layout).groups[1].height, None); + d.click(d.hit(&Hit::Icon(2)).unwrap().center()); + d.settle(); + assert_eq!(flyout_rect(&d).size(), grown.size()); + d.click(d.hit(&Hit::Icon(3)).unwrap().center()); + d.settle(); + assert_eq!(flyout_rect(&d).width(), before.width(), "another group keeps its own width"); + let from = flyout_rect(&d).right_center() - vec2(2.0, 0.0); + d.drag(from, from + vec2(20.0, 0.0)); + d.click(icon); + d.settle(); + assert_eq!(flyout_rect(&d).size(), grown.size(), "resizing another group leaves this one intact"); + d.click(d.hit(&Hit::Icon(2)).unwrap().center()); + d.click(icon); + d.settle(); + assert_eq!(flyout_rect(&d).size(), grown.size()); + #[cfg(feature = "serde")] + { + let saved = serde_json::to_string(&d.layout).unwrap(); + let mut restored = DockDriver::new(serde_json::from_str(&saved).unwrap(), Short); + restored.click(restored.hit(&Hit::Icon(1)).unwrap().center()); + restored.settle(); + assert_eq!(flyout_rect(&restored).size(), grown.size()); + } +} + +/// Horizontal dragging must not pin an automatic height or overwrite a saved height +/// temporarily clamped by a smaller viewport. Minimums, float bounds and locks still apply. +#[test] +fn flyout_resize_respects_limits_and_preserves_height_on_width_only_drags() { + for height in [None, Some(480.0)] { + let mut d = DockDriver::new(docked(&[&[1]], true), Content); + d.layout.apply(Action::ResizeGroup { panel: 1, height }).unwrap(); + d.float_bounds = Some(Rect::from_min_max(pos2(0.0, 30.0), pos2(800.0, 330.0))); + d.click(hit(&d, Hit::Icon(1)).center()); + d.settle(); + let before = flyout_rect(&d); + let from = before.right_center() - vec2(2.0, 0.0); + d.drag(from, from + vec2(70.0, 0.0)); + d.settle(); + assert!((flyout_rect(&d).width() - before.width() - 70.0).abs() < 1.0); + assert_eq!(first_column(&d.layout).groups[0].height, height); + d.float_bounds = None; + d.settle(); + if let Some(height) = height { + assert_eq!(flyout_rect(&d).height(), height); + } + let from = flyout_rect(&d).right_bottom() - vec2(2.0, 2.0); + d.drag(from, from - vec2(800.0, 800.0)); + d.settle(); + let small = flyout_rect(&d); + let style = DockStyle::default(); + assert_eq!(small.width(), style.min_pane); + assert_eq!(small.height(), style.tab_height + style.min_pane); + let from = small.right_bottom() - vec2(2.0, 2.0); + d.drag(from, from + vec2(1600.0, 1600.0)); + d.settle(); + assert!(d.screen.contains_rect(flyout_rect(&d))); + let saved = d.layout.clone(); + d.locked = true; + d.settle(); + let from = flyout_rect(&d).center_bottom() - vec2(0.0, 2.0); + d.drag(from, from - vec2(0.0, 60.0)); + assert_eq!(d.layout, saved); + } +} + +/// Persisted widths use the same finite geometry validation as column widths. +#[test] +fn flyout_width_rejects_invalid_geometry_without_mutating_the_layout() { + let mut layout = docked(&[&[1]], true); + let original = layout.clone(); + for width in [0.0, -1.0, f32::NAN, f32::INFINITY, 2_000_000.0] { + assert_eq!(layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: Some(width) }), Err(DockError::InvalidGeometry)); + assert_eq!(layout, original); + } + layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: Some(400.0) }).unwrap(); + let location = layout.location(&1).unwrap(); + layout.apply(Action::Close { panel: 1 }).unwrap(); + layout.restore(1, &location).unwrap(); + assert_eq!(first_column(&layout).groups[0].flyout_width, Some(400.0)); + layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: None }).unwrap(); + assert_eq!(layout, original); +} + +/// Float bounds can be shorter than the normal minimum; that must not disable width drags. +#[test] +fn a_flyout_constrained_below_minimum_height_can_still_resize_its_width() { + for height in [20.0, 50.0] { + let mut d = DockDriver::new(docked(&[&[1]], true), Content); + d.float_bounds = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, height))); + d.click(hit(&d, Hit::Icon(1)).center()); + d.settle(); + let before = d.ctx.read_response(d.id.with("flyout").with("resize")).unwrap().rect.right_bottom(); + // The side handle needs room above and below its corners, so the bottom-right + // corner exercises both axes in the shortest viewport as well. + let from = before - vec2(2.0, 2.0); + d.drag(from, from + vec2(60.0, 0.0)); + d.settle(); + assert_eq!(first_column(&d.layout).groups[0].flyout_width, Some(300.0)); + assert_eq!(first_column(&d.layout).groups[0].height, None); + } +} + +/// A host-owned header permits smaller flyouts than stock chrome on either dock side. +/// Both axes can grow from that size, shrink to the configured minimum, and stay in bounds. +#[test] +fn custom_small_flyouts_resize_within_their_configured_limits() { + for right in [false, true] { + let mut d = DockDriver::new(iconic_on_side(right), Content).with_style(|style| { + style.min_pane = 20.0; + style.tab_height = 10.0; + }); + d.layout.apply(Action::ResizeGroup { panel: 1, height: Some(30.0) }).unwrap(); + d.layout.apply(Action::ResizeFlyoutWidth { panel: 1, width: Some(32.0) }).unwrap(); + d.click(hit(&d, Hit::Icon(1)).center()); + d.settle(); + let id = d.id.with("flyout"); + let side = if right { "resize-left" } else { "resize-right" }; + let corner = if right { "resize-bottom-left" } else { "resize" }; + let handle = |d: &Driver, name| d.ctx.read_response(id.with(name)).unwrap().rect; + let saved = |d: &Driver| d.layout.location(&1).unwrap().group.unwrap(); + let before = handle(&d, side); + let anchor = if right { before.left() + 32.0 } else { before.right() - 32.0 }; + let top = before.top() - 12.0; + let direction = if right { -1.0 } else { 1.0 }; + + d.drag(before.center(), before.center() + vec2(direction * 20.0, 0.0)); + d.settle(); + assert_eq!(saved(&d).flyout_width, Some(52.0), "right={right}"); + assert_eq!(saved(&d).height, Some(30.0)); + let after = handle(&d, side); + assert_eq!(if right { after.left() + 52.0 } else { after.right() - 52.0 }, anchor); + + let bottom = handle(&d, "resize-bottom"); + d.drag(bottom.center(), bottom.center() + vec2(0.0, 20.0)); + d.settle(); + assert_eq!(saved(&d).height, Some(50.0)); + assert_eq!(saved(&d).flyout_width, Some(52.0)); + assert_eq!(handle(&d, side).top() - 12.0, top); + + let from = handle(&d, corner).center(); + d.drag(from, from - vec2(direction * 1000.0, 1000.0)); + d.settle(); + assert_eq!(saved(&d).flyout_width, Some(20.0)); + assert_eq!(saved(&d).height, Some(30.0)); + + let from = handle(&d, corner).center(); + d.drag(from, from + vec2(direction * 2000.0, 2000.0)); + d.settle(); + let side = handle(&d, side); + let bottom = handle(&d, corner).bottom(); + let rect = if right { + Rect::from_min_max(pos2(side.left(), top), pos2(anchor, bottom)) + } else { + Rect::from_min_max(pos2(anchor, top), pos2(side.right(), bottom)) + }; + assert!(d.screen.contains_rect(rect)); + } +} + #[test] fn collapsed_column_shows_one_icon_per_panel_with_group_separators() { let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 26c3eaf4..7e5abff0 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -467,6 +467,11 @@ pub enum Action

{ panel: P, height: Option, }, + /// Preferred flyout width of the dock group holding `panel`; `None` uses its column. + ResizeFlyoutWidth { + panel: P, + width: Option, + }, ResizeIconic { panel: P, width: Option, @@ -747,7 +752,7 @@ impl Layout

{ }; let own = groups.get(group).ok_or(DockError::MissingPanel)?; let mut location = group_location(&own.panels, panel).ok_or(DockError::MissingPanel)?; - location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, column }); + location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, flyout_width: own.flyout_width, column }); if location.anchor.is_some() { return Ok(location); } @@ -810,14 +815,21 @@ impl Layout

{ } let state = location.group.as_ref(); if state.is_some_and(|s| { - !valid_height(s.height) || s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w))) + !valid_height(s.height) + || s.flyout_width.is_some_and(|w| !valid_width(w)) + || s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w))) }) { return Err(DockError::InvalidGeometry); } // A restore that creates a group, column or dock gives it the saved state. let moving = || { - let group = - Group { panels: vec![panel.clone()], active: 0, minimized: state.is_some_and(|s| s.minimized), height: state.and_then(|s| s.height) }; + let group = Group { + panels: vec![panel.clone()], + active: 0, + minimized: state.is_some_and(|s| s.minimized), + height: state.and_then(|s| s.height), + flyout_width: state.and_then(|s| s.flyout_width), + }; let column = state.and_then(|s| s.column); Moving { width: column.map(|c| c.width), @@ -1211,6 +1223,15 @@ impl Layout

{ entry.height = height; Ok(()) } + Action::ResizeFlyoutWidth { panel, width } => { + if width.is_some_and(|w| !valid_width(w)) { + return Err(DockError::InvalidGeometry); + } + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + let group = self.group_mut(&place).ok_or(DockError::InvalidPlacement)?; + group.flyout_width = width; + Ok(()) + } Action::SetIconic { panel, iconic } => { self.column_of(&panel)?.iconic = iconic; Ok(()) @@ -1395,8 +1416,9 @@ fn check_leaf<'a, P: Eq + Hash>(node: &'a Node

, seen: &mut HashSet<&'a P>) -> } } +/// Validate group dimensions and nonempty membership, then check the active tab and uniqueness. fn check_group<'a, P: Eq + Hash>(group: &'a Group

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { - if group.panels.is_empty() || !valid_height(group.height) { + if group.panels.is_empty() || !valid_height(group.height) || group.flyout_width.is_some_and(|w| !valid_width(w)) { return Err(DockError::InvalidGeometry); } check_tabs(&group.panels, group.active, seen) diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 02537b0c..6f583da5 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -9,7 +9,7 @@ use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, U use super::{GroupKind, Renderer, dock::Edge}; use crate::docking::{ - Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag, + Action, Column, DockStyle, Group, GroupDrag, GroupGesture, PanelLimits, begin_drag, chrome::{ChromeStyle, FloatingFrame}, is_dragging, targets::{DockDrop, DockTarget, horizontal_line, usable}, @@ -298,8 +298,16 @@ impl Renderer<'_, P> { close_flyout::

(ctx, self.id); return; } - let bounds = self.workspace.intersect(self.float_bounds); - let width = request.width.max(self.style.min_pane).min(bounds.width()); + let mut bounds = self.workspace.intersect(self.float_bounds); + // Keep the column-side edge anchored, even when the saved width exceeds the room. + match request.edge { + Edge::Left => bounds.min.x = bounds.min.x.max(request.column.right()), + Edge::Right => bounds.max.x = bounds.max.x.min(request.column.left()), + } + if !usable(bounds) { + return; + } + let width = request.group.flyout_width.unwrap_or(request.width).max(self.style.min_pane).min(bounds.width()); let max = self .content .floating_size(&active) @@ -307,9 +315,11 @@ impl Renderer<'_, P> { .or(request.group.height) .filter(|h| h.is_finite() && *h > 0.0) .unwrap_or(FLYOUT_HEIGHT); - // A flyout fits the content its panel last drew, up to that height. - let height = self - .fitted_height(&active, self.style.tab_height, max) + // Only untouched heights fit content: otherwise the next frame undoes a resize. + let height = request + .group + .height + .or_else(|| self.fitted_height(&active, self.style.tab_height, max)) .unwrap_or(max) .max(self.style.tab_height + self.style.min_pane) .min(bounds.height()); @@ -321,9 +331,43 @@ impl Renderer<'_, P> { let rect = Rect::from_min_size(pos2(left, top), vec2(width, height)); let style = ChromeStyle::from_dock(self.style); let group = request.group; - let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| { - self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock); + let movable = group.panels.iter().all(|panel| self.permissions(panel).movable); + // In a viewport smaller than the minimum, keep the other axis resizable instead + // of asking AnchoredResize to reject an already-constrained starting rectangle. + let limits = + PanelLimits { min: vec2(self.style.min_pane, self.style.tab_height + self.style.min_pane).min(bounds.size()), max: bounds.size() }; + let label = self.style.resize_window_label.clone(); + let response = FloatingFrame::new(id, rect) + .bounds(bounds) + .limits(limits) + .resizable(movable) + .resize_edges([matches!(request.edge, Edge::Right), matches!(request.edge, Edge::Left), false, true]) + .reserve_top_resize_band(false) + .resize_label(&label) + .show(ctx, &style, |ui, rect| { + self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock); + }); + for handle in &response.handles { + self.register_interaction(ctx, handle.id); + } + let down = ctx.input(|input| input.pointer.primary_down()); + ctx.data_mut(|data| { + let key = self.id.with("pointer-resize"); + if let Some(started) = response.resize_started { + data.insert_temp(key, started); + } else if !down && data.get_temp::(key).is_some_and(|id| response.handles.iter().any(|handle| handle.id == id)) { + data.remove::(key); + } }); + if let Some(resized) = response.resized { + // Width-only drags preserve automatic fitting and heights clamped by a small viewport. + if resized.height() != response.rect.height() { + self.output.actions.push(Action::ResizeGroup { panel: active.clone(), height: Some(resized.height()) }); + } + if resized.width() != response.rect.width() { + self.output.actions.push(Action::ResizeFlyoutWidth { panel: active, width: Some(resized.width()) }); + } + } self.iconic.rect = Some(response.rect); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 4bff6035..8f856864 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -1227,6 +1227,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { return false; } super::hits::named(self.id, layout).contains_key(&focused) + || (super::flyout::

(ctx, self.id).is_some() + && super::chrome::RESIZE_HANDLES.iter().any(|name| self.id.with("flyout").with(name) == focused)) } /// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate