From 954ad06d250399e3ebc18ac9a0fad785eb7d7445 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:05:42 +0000 Subject: [PATCH 1/5] docking: Placement::Edge splits the layout's outer edge A frame along one edge of the docked layout, a quarter of its size. In a layout with docks the split wraps the part between them, so a bottom Timeline or top bar stays inside the docks. Behaviour follows filmcraft dock_ui.rs (workspace_edge). --- crates/ui/src/docking/dock_model.rs | 39 +++++++++++++++++++++++++++- crates/ui/src/docking/model.rs | 8 +++++- crates/ui/src/docking/model_tests.rs | 23 ++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index d4200fe3..05b3409f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -6,7 +6,7 @@ use std::hash::Hash; -use super::model::{DockError, Floating, Layout, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; +use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; use crate::layout::{SplitAxis, SplitSize}; /// Width of a new column when nothing better is known. @@ -242,6 +242,25 @@ impl Layout

{ None } + /// The node an edge split wraps: the root, or the part between the docks. + pub(in crate::docking) fn edge_path(&self) -> Vec { + let mut path = Vec::new(); + let mut node = self.root.as_ref(); + while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node { + if path.len() > MAX_DEPTH { + break; + } + let second_side = match (&**first, &**second) { + (Node::Dock { .. }, _) => true, + (_, Node::Dock { .. }) => false, + _ => break, + }; + path.push(second_side); + node = Some(if second_side { second } else { first }); + } + path + } + pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec>> { match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { Some(Node::Dock { columns, .. }) => Some(columns), @@ -494,6 +513,24 @@ impl Layout

{ groups.splice(index..index, moving.groups); Ok(()) } + Placement::Edge(zone) => { + if zone == Zone::Center || matches!(place, Place::Floating { .. }) { + return Err(DockError::InvalidPlacement); + } + let path = self.edge_path(); + let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?; + if matches!(node, Node::Dock { .. }) { + return Err(DockError::InvalidPlacement); + } + let (panels, active) = flatten(moving.groups); + let new = Node::Tabs { panels, active }; + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let (first, second, size) = + if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) }; + *node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) }; + Ok(()) + } Placement::Column { after } => { let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) }; let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 92eeedbb..952092ea 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -169,6 +169,10 @@ pub enum Placement

{ Group { below: bool }, /// A new column before (towards the window edge) or after the anchor's column. Column { after: bool }, + /// A new frame along one edge of the docked layout, a quarter of its width or height: the + /// root is split, or in a layout with docks, the part between them. The anchor names any + /// docked panel. `Center` is invalid. + Edge(Zone), } /// A closed panel's former placement. Applications may retain this beside visibility flags. @@ -970,7 +974,9 @@ impl Layout

{ fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { let place = self.place(anchor).ok_or(DockError::MissingTarget)?; - if matches!(place, Place::Dock { .. } | Place::Floating { .. }) || matches!(placement, Placement::Group { .. } | Placement::Column { .. }) { + if matches!(place, Place::Dock { .. } | Place::Floating { .. }) + || matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_)) + { let group = Group::new(vec![entry.panel], 0); return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); } diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index bbd9ae05..88ca72f6 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -470,3 +470,26 @@ mod serde_tests { } } } + +#[test] +fn edge_placement_splits_the_part_between_the_docks_or_the_root() { + // Dock layout: a floating window lands under the frames, beside the end dock. + let mut layout = docked(); + layout.apply(Action::MoveWindow { panel: 6, to: Target::At { anchor: 1, placement: Placement::Edge(Zone::Bottom) } }).unwrap(); + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("{layout:?}") }; + assert!(matches!(&**second, Node::Dock { .. })); + let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(size), second: bottom, .. } = &**first else { panic!("{first:?}") }; + assert_eq!(*size, 0.75); + assert_eq!(**bottom, tabs(&[6, 7, 8], 1)); + assert!(layout.floating.is_empty()); + + // Frame layout: one panel takes the whole left edge. + let mut frames = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), tabs(&[2, 3], 0))), floating: Vec::new() }; + frames.apply(Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }).unwrap(); + let Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(size), first, .. }) = &frames.root else { panic!() }; + assert_eq!((*size, &**first), (0.25, &tabs(&[3], 0))); + + let before = frames.clone(); + assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement)); + assert_eq!(frames, before); +} From 17cf8cd07bbf1726c1e4d92caeff26fdb9ccc02d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:19:33 +0000 Subject: [PATCH 2/5] docking: public floating window chrome docking::chrome holds the parts of a floating window that hosts other than DockArea need, such as office task panes: FloatingFrame (opaque frame on its own layer, anchored resize from both sides, the bottom and both bottom corners, arrow keys on a focused handle) and TitleBar (two grip lines or a title, an optional menu button, and a close button 10 pt in from the right). They return responses and requested rects; the caller turns them into its own commands. Behaviour follows vectorcraft floating.rs (show_set: TITLE 14, grip lines, close position). --- crates/ui/src/docking/chrome.rs | 334 ++++++++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 1 + 2 files changed, 335 insertions(+) create mode 100644 crates/ui/src/docking/chrome.rs diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs new file mode 100644 index 00000000..2e2b14be --- /dev/null +++ b/crates/ui/src/docking/chrome.rs @@ -0,0 +1,334 @@ +//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with a +//! grip, an optional ▾ menu button and ×. [`super::DockArea`] draws its floating windows with +//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes +//! a layout: the caller turns the responses into its own commands. + +use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2}; + +use super::{DockStyle, PanelLimits, resize::AnchoredResize}; + +/// Height of a floating window's title bar. +pub const TITLE_HEIGHT: f32 = 14.0; + +/// Names of the resize handles, appended to the frame id: `id.with(name)`. +pub const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; + +/// Colours and font of the frame and title bar. +#[derive(Clone, Debug)] +pub struct ChromeStyle { + /// Painted under the contents, so the window stays opaque when the contents are not. + pub background: Color32, + pub stroke: Stroke, + pub title_background: Color32, + pub text: Color32, + pub weak_text: Color32, + pub accent: Color32, + pub font: FontId, +} + +impl ChromeStyle { + pub fn from_ui(ui: &Ui) -> Self { + Self::from_dock(&DockStyle::from_ui(ui)) + } + + pub fn from_dock(style: &DockStyle) -> Self { + Self { + background: style.floating_background, + stroke: style.floating_stroke, + title_background: style.tab_background, + text: style.text, + weak_text: style.inactive_text, + accent: style.accent, + font: style.font.clone(), + } + } +} + +/// Resize handle hit rects and the edges they move: both sides, the bottom and both bottom +/// corners. The top edge belongs to the title bar. +fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { + [ + ("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)), + ( + "resize-left", + Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)), + vec2(-1.0, 0.0), + ), + ( + "resize-right", + Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)), + vec2(1.0, 0.0), + ), + ( + "resize-bottom", + Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())), + vec2(0.0, 1.0), + ), + ( + "resize-bottom-left", + Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2((rect.left() + 12.0).min(rect.right()), rect.bottom())), + vec2(-1.0, 1.0), + ), + ] +} + +/// An opaque in-window frame on its own `Order::Middle` layer, `id`, at `rect`. +pub struct FloatingFrame<'a> { + id: Id, + rect: Rect, + bounds: Option, + limits: PanelLimits, + resizable: bool, + resize_label: &'a str, +} + +/// What a [`FloatingFrame`] did this frame. +pub struct FrameResponse { + pub inner: R, + /// Where the frame was drawn, inside its bounds. + pub rect: Rect, + pub layer: LayerId, + /// The rect a resize handle asks for: pointer drags anchored at the opposite edges, or + /// arrow keys on a focused handle (8 pt per press). Escape and focus loss end a resize. + pub resized: Option, + /// The handle whose pointer resize started this frame. + pub resize_started: Option, + /// Every handle's response, for focus and gesture bookkeeping. + pub handles: Vec, +} + +impl<'a> FloatingFrame<'a> { + pub fn new(id: Id, rect: Rect) -> Self { + Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window" } + } + + /// The frame stays inside `bounds`; the default is the viewport's content rect. + pub fn bounds(mut self, bounds: Rect) -> Self { + self.bounds = Some(bounds); + self + } + + /// Size limits of a resize. The frame itself is drawn at the given rect. + pub fn limits(mut self, limits: PanelLimits) -> Self { + self.limits = limits; + self + } + + pub fn resizable(mut self, resizable: bool) -> Self { + self.resizable = resizable; + self + } + + /// Accessibility label of the resize handles. + pub fn resize_label(mut self, label: &'a str) -> Self { + self.resize_label = label; + self + } + + pub fn layer(id: Id) -> LayerId { + LayerId::new(Order::Middle, id) + } + + /// Draw the frame, then `add_contents` with the frame's rect, then the outline and handles. + pub fn show(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse { + let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect()); + let rect = clamp(self.rect, bounds); + let area = egui::Area::new(self.id).fade_in(false).order(Order::Middle).fixed_pos(rect.min) + .default_size(rect.size()) + .movable(false) + .constrain_to(bounds); + let shown = area.show(ctx, |ui| { + // Drawn at `rect` itself, also in egui's first (sizing) pass of a new area. + ui.set_min_size(rect.size()); + ui.allocate_rect(rect, Sense::hover()); + ui.painter().rect_filled(rect, 0.0, style.background); + let inner = add_contents(ui, rect); + ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); + let (resized, resize_started, handles) = + if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; + FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles } + }); + shown.inner + } + + fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option, Option, Vec) { + let mut resized = None; + let mut started = None; + let mut responses = Vec::new(); + let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; + let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + for (name, handle, direction) in handles(rect) { + if !super::targets::usable(handle) { + continue; + } + let cursor = if direction.x == 0.0 { + egui::CursorIcon::ResizeVertical + } else if direction.y == 0.0 { + egui::CursorIcon::ResizeHorizontal + } else if direction.x == direction.y { + egui::CursorIcon::ResizeNwSe + } else { + egui::CursorIcon::ResizeNeSw + }; + let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.resize_label)); + let anchor_id = response.id.with("pointer-anchor"); + if response.drag_started() { + response.request_focus(); + if let Some(origin) = ui.input(|i| i.pointer.press_origin()) + && let Some(anchor) = AnchoredResize::new(rect, origin, direction) + { + ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor)); + started = Some(response.id); + } + } + let mut delta = Vec2::ZERO; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input_mut(|input| { + for (key, step) in [(Key::ArrowLeft, vec2(-8.0, 0.0)), (Key::ArrowRight, vec2(8.0, 0.0)), (Key::ArrowUp, vec2(0.0, -8.0)), (Key::ArrowDown, vec2(0.0, 8.0))] { + if input.consume_key(egui::Modifiers::NONE, key) { + delta += step; + } + } + }); + } + let next = if response.dragged() || response.drag_stopped() { + ui.ctx() + .data(|data| data.get_temp::(anchor_id)) + .and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, bounds, limits))) + } else if delta != Vec2::ZERO { + AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, bounds, limits)) + } else { + None + }; + if response.enabled() + && !cancelled + && let Some(next) = next.filter(|next| *next != rect) + { + resized = Some(next); + } + if response.drag_stopped() { + response.surrender_focus(); + } + if cancelled || !ui.input(|input| input.pointer.primary_down()) { + ui.ctx().data_mut(|data| data.remove::(anchor_id)); + } + if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { + ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent)); + } + responses.push(response); + } + (resized, started, responses) + } +} + +/// Keep `rect` inside `bounds`, shrinking it when it is larger. +pub fn clamp(rect: Rect, bounds: Rect) -> Rect { + if !super::targets::usable(bounds) { + return rect; + } + let size = rect.size().max(vec2(1.0, 1.0)).min(bounds.size()); + let min = rect.min.max(bounds.min).min(bounds.max - size); + Rect::from_min_size(min, size) +} + +/// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt +/// in from the right edge. The bar senses clicks and drags; double-clicks are the caller's. +pub struct TitleBar<'a> { + id: Id, + move_label: &'a str, + title: Option, + menu: Option<&'a str>, + close: Option<&'a str>, + movable: bool, +} + +/// The parts of a [`TitleBar`] that were shown. +pub struct TitleResponse { + /// Drag to move; double-click is the caller's (docking minimizes every group). + pub bar: Response, + pub menu: Option, + pub close: Option, +} + +impl<'a> TitleBar<'a> { + /// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`. + /// `move_label` names the bar for accessibility. + pub fn new(id: Id, move_label: &'a str) -> Self { + Self { id, move_label, title: None, menu: None, close: None, movable: true } + } + + /// Text at the left instead of the grip lines. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Show a ▾ button with this accessibility label. + pub fn menu(mut self, label: &'a str) -> Self { + self.menu = Some(label); + self + } + + /// Show a × button with this accessibility label. + pub fn close(mut self, label: &'a str) -> Self { + self.close = Some(label); + self + } + + /// Whether the bar shows the grab cursor. It senses drags either way. + pub fn movable(mut self, movable: bool) -> Self { + self.movable = movable; + self + } + + pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse { + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + painter.rect_filled(rect, 0.0, style.title_background); + let side = rect.height(); + let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side))); + let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0); + let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom()))); + let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left()); + let bar_rect = Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom())); + let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag()); + let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar }; + bar.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.move_label)); + if let Some(title) = self.title { + let galley = title.into_galley(ui, Some(egui::TextWrapMode::Truncate), (bar_rect.width() - 12.0).max(0.0), style.font.clone()); + painter.galley(pos2(rect.left() + 6.0, rect.center().y - galley.size().y * 0.5), galley, style.text); + } else { + let grip = Stroke::new(0.6, style.weak_text); + for k in 0..2 { + let y = rect.center().y - 1.5 + k as f32 * 3.0; + painter.line_segment([pos2(rect.center().x - 9.0, y), pos2(rect.center().x + 9.0, y)], grip); + } + } + if bar.has_focus() { + painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside); + } + let button = |ui: &mut Ui, rect: Rect, name: &str, label: &str| { + let response = ui.interact(rect, self.id.with(name), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + let color = if response.hovered() || response.has_focus() { style.text } else { style.weak_text }; + (response, Stroke::new(1.2, color)) + }; + let menu = menu_rect.zip(self.menu).map(|(menu_rect, label)| { + let (response, stroke) = button(ui, menu_rect, "menu", label); + let c = menu_rect.center(); + painter.add(egui::Shape::convex_polygon(vec![c + vec2(-3.5, -1.5), c + vec2(3.5, -1.5), c + vec2(0.0, 2.5)], stroke.color, Stroke::NONE)); + response + }); + let close = close_rect.zip(self.close).map(|(close_rect, label)| { + let (response, stroke) = button(ui, close_rect, "close", label); + let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))); + painter.line_segment([r.left_top(), r.right_bottom()], stroke); + painter.line_segment([r.right_top(), r.left_bottom()], stroke); + response + }); + TitleResponse { bar, menu, close } + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 849a5e59..2c58dbff 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -5,6 +5,7 @@ //! Validate deserialized layouts before rendering them. Every transaction checks its complete //! input and commits only a valid result; a rejected move cannot lose the source panel. +pub mod chrome; mod dock_model; mod model; mod render; From 40fefd9c7bd1fd1c06893210b07ca3a06923a3bd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:19:44 +0000 Subject: [PATCH 3/5] docking: floating title bars and stacks, frame profile, native windows Floating windows: - A title bar (TITLE 14) drags the whole window live; Escape and focus loss put it back. A release over a tab strip, a floating stack's band or seam, or the outer edge emits MoveWindow; elsewhere the window stays and hosts get group_drop. Double-click minimizes or expands every group; the close button closes every panel (CloseWindow). - Stacked groups with a 3 pt bar between them and dividers that resize both neighbours (ResizeGroup). The top and bottom quarter of a group and the seam between two groups insert a new group there, with a line preview (P3, P4). The title bar occludes what lies behind it. - The only group of a window carries the window rect when dragged by its bar, so floating it again keeps the window's size. Frame profile (DockStyle::profile): - Ctrl/Cmd held at the start of a tab drag undocks the panel (S9). - The outer edge band splits at the root: top and bottom in both profiles, left and right in the frame profile (P8). - Menu tail: Undock Panel, Close Panel Group, Undock Panel Group, Close Other Panels in Group and Maximize Frame (DockEvent::ToggleMaximize). DockArea::maximized draws only the docked frame holding the panel; other frames and floating windows do not render or take input. DockArea::hide_panels leaves out every panel, or every panel except a host-chosen set; protected panels stay and the layout is unchanged. Feature native-windows: a floating window with a native rect gets its own OS window through show_viewport_immediate when the backend does not embed viewports, and stays in-window otherwise. Closing it emits SetNative None, so the group returns to its in-window rect; moves and resizes are read back as SetNative with bounded sizes and positions. Tabs and groups do not drag between OS windows. Move Panel to New Window is in the menu tail where native windows are available. A floating panel whose window closed as a whole restores at its last rect. Behaviour follows vectorcraft floating.rs (show_set, draw_groups, track), effectcraft dock_ui.rs (panel_window, set_panel_window, toggle_maximize) and dock.rs (PanelWindow::sanitized), filmcraft dock_ui.rs (workspace_edge, EDGE_BAND, maximized) and storytold/filmcraft#2118 (saved_native_rect, bounded_inner_size). --- crates/ui/Cargo.toml | 2 + crates/ui/src/docking/floating_tests.rs | 534 +++++++++++++++++++++++ crates/ui/src/docking/group_tests.rs | 2 +- crates/ui/src/docking/mod.rs | 7 +- crates/ui/src/docking/model.rs | 9 +- crates/ui/src/docking/render/floating.rs | 426 +++++++++++------- crates/ui/src/docking/render/frame.rs | 231 ++++++++++ crates/ui/src/docking/render/group.rs | 59 +-- crates/ui/src/docking/render/mod.rs | 16 +- crates/ui/src/docking/render/native.rs | 154 +++++++ crates/ui/src/docking/render/preview.rs | 60 ++- crates/ui/src/docking/surface.rs | 110 ++++- crates/ui/src/docking/tests.rs | 8 +- 13 files changed, 1374 insertions(+), 244 deletions(-) create mode 100644 crates/ui/src/docking/floating_tests.rs create mode 100644 crates/ui/src/docking/render/frame.rs create mode 100644 crates/ui/src/docking/render/native.rs diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 0b8edc2f..dd85edd4 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"], optional = true } [features] serde = ["dep:serde"] +# Floating windows may move to their own OS windows (desktop egui backends with viewports). +native-windows = [] [dev-dependencies] egui = { workspace = true, features = ["default_fonts"] } diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs new file mode 100644 index 00000000..8d8dc758 --- /dev/null +++ b/crates/ui/src/docking/floating_tests.rs @@ -0,0 +1,534 @@ +//! Floating windows (title bar, stacks, close), the frame profile (outer edge, Maximize, +//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome. + +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, Vec2, pos2, vec2}; + +const AREA: &str = "floating-area"; + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// `[1] | [2, 3]` with panel 3 selected. +fn frames() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(300.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2, 3], 1)), + }), + floating: Vec::new(), + } +} + +/// A start dock (`[2, 3]` above `[4]`) beside protected canvas 0; window W1 stacks `[5]` above +/// `[6, 7]`; window W2 holds `[8]`. +fn workspace() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(Node::Dock { + side: Side::Start, + columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)], + }), + second: Box::new(tabs(&[0], 0)), + }), + floating: vec![ + Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [300.0, 80.0, 240.0, 300.0], native: None }, + Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]), + ], + } +} + +#[derive(Default)] +struct Content { + native: bool, + keep: Vec, +} + +impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn permissions(&self, panel: &u32) -> Permissions { + if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() } + } + fn allow_native(&self, _: &u32) -> bool { + self.native + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Hide { + None, + All, + Keep, +} + +struct Driver { + ctx: Context, + layout: Layout, + content: Content, + profile: Profile, + maximized: Option, + hide: Hide, + modifiers: Modifiers, + time: f64, +} + +impl Driver { + fn new(layout: Layout, profile: Profile) -> Self { + let mut driver = Self { + ctx: Context::default(), + layout, + content: Content::default(), + profile, + maximized: None, + hide: Hide::None, + modifiers: Modifiers::NONE, + time: 0.0, + }; + driver.step(vec![]); + driver.step(vec![]); + driver + } + + fn frame(&mut self, mut events: Vec) -> DockOutput { + let mut output = DockOutput::default(); + events.insert(0, Event::ModifiersChanged(self.modifiers)); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + events, + focused: true, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, profile, maximized, hide) = (&self.layout, &mut self.content, self.profile, self.maximized, self.hide); + let keep: Vec = content.keep.clone(); + let keep = move |p: &u32| keep.contains(p); + let full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + style.profile = profile; + let hide = match hide { + Hide::None => HidePanels::None, + Hide::All => HidePanels::All, + Hide::Keep => HidePanels::Except(&keep), + }; + output = DockArea::new(Id::new(AREA)).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content); + }); + let mut full = full; + full.textures_delta.clear(); + output + } + + fn step(&mut self, events: Vec) -> DockOutput { + let output = self.frame(events); + assert!(output.error.is_none(), "{output:?}"); + for action in &output.actions { + self.layout.apply_with_permissions(action.clone(), |p| self.content.permissions(p)).unwrap(); + } + output + } + + fn response(&self, id: Id) -> Option { + self.ctx.read_response(id).map(|r| r.rect) + } + + fn title(&self, first: u32) -> Rect { + self.response(Id::new(AREA).with(("move-group", Some(&first)))).unwrap() + } + + fn tab(&self, panel: u32) -> Option { + self.response(Id::new(AREA).with(("tab", &panel))) + } + + fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(from), button(from, true)]); + self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + self.step(vec![Event::PointerMoved(to)]) + } + + fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + fn click(&mut self, at: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(at), button(at, true)]); + self.step(vec![button(at, false)]) + } + + fn wait(&mut self) { + for _ in 0..45 { + self.step(vec![]); + } + } +} + +fn done(mut full: egui::FullOutput) { + full.textures_delta.clear(); +} + +fn button(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn window_of(layout: &Layout, panel: u32) -> Option<&Floating> { + layout.floating.iter().find(|w| w.contains(&panel)) +} + +fn groups_of(window: &Floating) -> Vec> { + window.groups.iter().map(|g| g.panels.clone()).collect() +} + +#[test] +fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() { + // The window follows the pointer with every group; Escape puts it back. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + d.hold(title, title + vec2(40.0, 30.0)); + assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]); + assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![5], vec![6, 7]]); + d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + d.step(vec![button(title + vec2(40.0, 30.0), false)]); + assert_eq!(d.layout, workspace()); + + // Released over another window's lower band: its groups stack below that window's. + let out = d.drag(title, pos2(660.0, 480.0)); + assert!( + out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true } } }), + "{:?}", + out.actions + ); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]); + + // Released over a docked group's strip: every panel joins it as tabs. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + let strip = d.tab(4).unwrap().center(); + let out = d.drag(title, strip); + assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 4, .. } })), "{:?}", out.actions); + assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 7).is_none()); + assert_eq!(d.layout.place(&4), d.layout.place(&6)); + + // Released over the canvas: the window stays where it was moved and hosts get the release. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + let out = d.drag(title, pos2(500.0, 300.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions); + assert_eq!(out.group_drop, Some((5, pos2(500.0, 300.0)))); + assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]); +} + +#[test] +fn floating_stack_seams_and_bands_insert_groups() { + // A group dragged by its bar to another window's top band stacks above that group. + let mut d = Driver::new(workspace(), Profile::Dock); + let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center(); + let out = d.drag(bar, pos2(660.0, 360.0)); + assert!( + out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false } } }), + "{:?}", + out.actions + ); + assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]); + assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]); + + // A tab dropped on the seam between two stacked groups becomes a group between them. + let mut d = Driver::new(workspace(), Profile::Dock); + let tab = d.tab(8).unwrap().center(); + let seam = pos2(420.0, 237.0); + let out = d.drag(tab, seam); + assert!( + out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), + "{:?}", + out.actions + ); + assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); + + // The divider between two open groups resizes both. + let mut d = Driver::new(workspace(), Profile::Dock); + let divider = d.response(Id::new(AREA).with(("floating-divider", Some(&5u32)))).unwrap().center(); + d.drag(divider, divider + vec2(0.0, 40.0)); + let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect(); + assert!((heights[0] - 181.5).abs() < 2.0 && (heights[1] - 101.5).abs() < 2.0, "{heights:?}"); +} + +#[test] +fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() { + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + d.click(title); + d.click(title); + assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| g.minimized)); + d.wait(); + d.click(title); + d.click(title); + assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| !g.minimized)); + + let close = d.response(Id::new(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap(); + assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge"); + let before = d.layout.clone(); + let out = d.click(close.center()); + assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]); + assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7])); + assert!(window_of(&d.layout, 6).is_none() && d.layout.contains(&8)); +} + +#[test] +fn closed_floating_window_reopens_each_panel_at_its_last_rect() { + let mut layout = workspace(); + let locations: Vec<_> = [5, 6, 7].map(|p| (p, layout.location(&p).unwrap())).into(); + layout.apply(Action::CloseWindow { panel: 6 }).unwrap(); + for (panel, location) in locations { + layout.restore(panel, &location).unwrap(); + assert_eq!(window_of(&layout, panel).unwrap().rect, [300.0, 80.0, 240.0, 300.0], "{panel}"); + } +} + +#[test] +fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() { + let mut d = Driver::new(frames(), Profile::Frame); + let tab = d.tab(3).unwrap().center(); + let other = d.tab(1).unwrap().center(); + d.modifiers = Modifiers::COMMAND; + d.hold(tab, other); + d.modifiers = Modifiers::NONE; + let out = d.step(vec![button(other, false)]); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); + assert!(window_of(&d.layout, 3).is_some()); + + // Without the modifier the same drag joins the other frame. + let mut d = Driver::new(frames(), Profile::Frame); + let out = d.drag(tab, other); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions); +} + +#[test] +fn outer_edge_band_splits_at_the_root() { + // Frame profile: all four edges. + let mut d = Driver::new(frames(), Profile::Frame); + let tab = d.tab(3).unwrap().center(); + let out = d.drag(tab, pos2(3.0, 300.0)); + assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions); + let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; + assert_eq!(**first, tabs(&[3], 0)); + + // Dock profile: the bottom edge of the part between the docks, for a Timeline. + let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock); + let tab = d.tab(2).unwrap().center(); + let out = d.drag(tab, pos2(500.0, 597.0)); + assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions); + let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() }; + assert!(matches!(**first, Node::Dock { .. })); + assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0))); + + // Its left and right edges are the docks' (new columns and docks), not root splits. + let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock); + let tab = d.tab(4).unwrap().center(); + let out = d.drag(tab, pos2(246.0, 300.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions); +} + +#[test] +fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() { + let mut layout = frames(); + layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0])); + let mut d = Driver::new(layout.clone(), Profile::Frame); + let window_title = d.title(5).center(); + d.maximized = Some(2); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.tab(2).unwrap().left(), 0.0, "the maximized frame starts at the area's edge"); + assert!(d.tab(1).is_none()); + assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none()); + let out = d.drag(window_title, window_title + vec2(30.0, 30.0)); + assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions); + + // A floating panel does not maximize. + let mut d = Driver::new(layout, Profile::Frame); + d.maximized = Some(5); + d.step(vec![]); + d.step(vec![]); + assert!(d.tab(1).is_some()); +} + +#[test] +fn close_other_panels_keeps_only_the_chosen_tab() { + use egui_kittest::{Harness, kittest::Queryable}; + type State = (Content, Vec>, Vec>); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, (content, actions, events): &mut State| { + let mut style = DockStyle::from_ui(ui); + style.profile = Profile::Frame; + let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content); + actions.extend(out.actions); + events.extend(out.events); + }, + (Content::default(), Vec::new(), Vec::new()), + ); + h.run_steps(2); + let at = h.ctx.read_response(Id::new(AREA).with(("tab", &3u32))).unwrap().rect.center(); + let open_menu = |h: &mut Harness<'_, State>| { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + open_menu(&mut h); + for label in ["Undock Panel", "Close Panel Group", "Undock Panel Group", "Close Other Panels in Group", "Maximize Frame"] { + assert_eq!(h.query_all_by_label(label).count(), 1, "{label}"); + } + assert_eq!(h.query_all_by_label("Minimize").count(), 0); + h.get_by_label("Close Other Panels in Group").click(); + h.run_steps(2); + assert_eq!(h.state().1, vec![Action::CloseOthers { panel: 3 }]); + let mut layout = frames(); + layout.apply(Action::CloseOthers { panel: 3 }).unwrap(); + assert!(layout.contains(&3) && !layout.contains(&2)); + + open_menu(&mut h); + h.get_by_label("Maximize Frame").click(); + h.run_steps(2); + assert_eq!(h.state().2, vec![DockEvent::ToggleMaximize { panel: 3 }]); +} + +#[test] +fn hide_panels_keeps_protected_panels_and_the_except_set() { + let mut d = Driver::new(workspace(), Profile::Dock); + d.hide = Hide::All; + d.step(vec![]); + d.step(vec![]); + for panel in [2, 3, 4, 5, 6, 8] { + assert!(d.tab(panel).is_none(), "{panel}"); + } + assert!(d.layout == workspace(), "hiding leaves the layout unchanged"); + + d.hide = Hide::Keep; + d.content.keep = vec![4, 6]; + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.tab(4).map(|r| r.left()), Some(0.0)); + assert!(d.tab(6).is_some()); + for panel in [2, 3, 5, 7, 8] { + assert!(d.tab(panel).is_none(), "{panel}"); + } + // A window keeps its rect with its hidden groups left out. + d.step(vec![]); + assert_eq!(d.title(6).min, pos2(300.0, 80.0)); +} + +#[test] +fn native_window_falls_back_in_window_when_viewports_are_embedded() { + let mut layout = workspace(); + layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]); + let mut d = Driver::new(layout.clone(), Profile::Dock); + d.content.native = true; + assert!(d.ctx.embed_viewports()); + let out = d.step(vec![]); + assert!(out.actions.is_empty(), "{out:?}"); + assert_eq!(d.title(5).min, pos2(300.0, 80.0)); + assert_eq!(d.layout, layout); +} + +#[cfg(feature = "native-windows")] +#[test] +fn native_window_close_returns_the_group_to_its_in_window_rect() { + use std::{cell::Cell, rc::Rc}; + let close = Rc::new(Cell::new(false)); + let requested = close.clone(); + // A backend with real viewports: the child reports its place and, when asked, a close. + Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { + let mut info = egui::ViewportInfo { + outer_rect: Some(Rect::from_min_size(pos2(1210.0, 905.0), vec2(250.0, 330.0))), + inner_rect: Some(Rect::from_min_size(pos2(1210.0, 935.0), vec2(250.0, 300.0))), + ..Default::default() + }; + if requested.get() { + info.events.push(egui::ViewportEvent::Close); + } + let input = RawInput { + viewport_id: viewport.ids.this, + viewports: std::iter::once((viewport.ids.this, info)).collect(), + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(250.0, 300.0))), + ..Default::default() + }; + done(ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui))); + }); + let mut layout = workspace(); + layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]); + let mut d = Driver::new(Layout::default(), Profile::Dock); + d.ctx.set_embed_viewports(false); + d.content.native = true; + d.layout = layout; + let out = d.step(vec![]); + assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window"); + assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 905.0, 250.0, 300.0]) }), "{:?}", out.actions); + + close.set(true); + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: None }), "{:?}", out.actions); + assert_eq!(d.layout.floating[0].native, None); + assert_eq!(d.layout.floating[0].rect, [300.0, 80.0, 240.0, 300.0]); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.title(5).min, pos2(300.0, 80.0)); +} + +#[test] +fn chrome_frame_and_title_bar_work_without_a_dock_area() { + use chrome::{ChromeStyle, FloatingFrame, TitleBar}; + let ctx = Context::default(); + let id = Id::new("task-pane"); + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let mut time = 0.0; + let mut frame = |events: Vec| { + time += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() }; + let mut result = None; + done(ctx.run_ui(input, |ui| { + let style = ChromeStyle::from_ui(ui); + let shown = FloatingFrame::new(id, rect).show(ui.ctx(), &style, |ui, inner| { + let title = Rect::from_min_size(inner.min, vec2(inner.width(), 22.0)); + let bar = TitleBar::new(id.with("title"), "Move pane").title("Styles").menu("Pane menu").close("Close pane").show(ui, title, &style); + (bar.bar.dragged(), bar.bar.drag_delta(), bar.menu.is_some_and(|r| r.clicked()), bar.close.is_some_and(|r| r.clicked())) + }); + result = Some((shown.inner, shown.resized)); + })); + result.unwrap() + }; + frame(vec![]); + frame(vec![]); + let click = |frame: &mut dyn FnMut(Vec) -> ((bool, Vec2, bool, bool), Option), at| { + frame(vec![Event::PointerMoved(at), button(at, true)]); + frame(vec![button(at, false)]) + }; + let ((_, _, _, closed), _) = click(&mut frame, pos2(390.0, 111.0)); + assert!(closed, "× 10 pt in from the right"); + let ((_, _, menu, _), _) = click(&mut frame, pos2(374.0, 111.0)); + assert!(menu, "▾ left of ×"); + + frame(vec![Event::PointerMoved(pos2(200.0, 110.0)), button(pos2(200.0, 110.0), true)]); + frame(vec![Event::PointerMoved(pos2(210.0, 115.0))]); + let ((dragged, delta, ..), _) = frame(vec![Event::PointerMoved(pos2(230.0, 125.0))]); + assert!(dragged && delta == vec2(20.0, 10.0), "{delta:?}"); + frame(vec![button(pos2(230.0, 125.0), false)]); + + let edge = pos2(398.0, 200.0); + frame(vec![Event::PointerMoved(edge), button(edge, true)]); + frame(vec![Event::PointerMoved(edge + vec2(10.0, 0.0))]); + let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]); + assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0)))); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 7edcf28f..673b2133 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -527,7 +527,7 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { let mut d = Driver::new(layout); let from = d.rect("drag-bar", 6).center(); let out = d.drag(from, pos2(700.0, 300.0)); - assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 200.0]) })), "{:?}", out.actions); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 191.5]) })), "{:?}", out.actions); assert_eq!(d.layout.floating.len(), 2); assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 2c58dbff..eae01b86 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -16,12 +16,17 @@ mod workspace; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; -pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag, is_dragging}; +pub use surface::{ + DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag, + is_dragging, +}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; +#[cfg(test)] +mod floating_tests; #[cfg(test)] mod group_tests; #[cfg(test)] diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 952092ea..6800e565 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -576,7 +576,8 @@ impl Layout

{ } /// Restore an absent panel at a captured location. Missing anchors are reported without - /// changes, allowing the app to choose an explicit default. Unrelated tabs retain selection. + /// changes, allowing the app to choose an explicit default; a floating panel whose + /// window is gone floats at its last rect instead. Unrelated tabs retain selection. pub fn restore(&mut self, panel: P, location: &Location

) -> Result<(), DockError> { self.validate()?; if self.contains(&panel) { @@ -608,10 +609,8 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } - if let Some(anchor) = &location.anchor { - if !proposed.floating.iter().any(|g| g.contains(anchor)) { - return Err(DockError::MissingTarget); - } + // A window that closed as a whole reopens at its last rect. + if let Some(anchor) = location.anchor.as_ref().filter(|anchor| proposed.floating.iter().any(|g| g.contains(anchor))) { if let Placement::Group { .. } = location.placement { proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; } else { diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 43d18e25..383a68b4 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -1,176 +1,308 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2}; +use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2}; -use super::{GroupKind, Renderer, clamp_float}; -use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize}; +use super::{GroupKind, Renderer, clamp_float, node_limits}; +use crate::docking::chrome::{ChromeStyle, FloatingFrame, TitleBar}; +use crate::docking::{Action, DropProposal, Floating, Group, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; -/// Floating window resize handles: id suffix, hit rect and the edges they move. -fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { - [ - ("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)), - ( - "resize-left", - Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)), - vec2(-1.0, 0.0), - ), - ( - "resize-right", - Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)), - vec2(1.0, 0.0), - ), - ( - "resize-bottom", - Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())), - vec2(0.0, 1.0), - ), - ( - "resize-bottom-left", - Rect::from_min_max( - pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), - pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()), - ), - vec2(-1.0, 1.0), - ), - ] +pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES; + +/// Height of the bar between two stacked groups of a floating window. +const GAP: f32 = 3.0; +/// Height of the band around a seam between stacked groups that inserts a new group there. +const SEAM_BAND: f32 = 8.0; + +/// A floating stack as drawn, for its band and seam targets. +pub(in crate::docking) struct StackHit

{ + pub(super) window: Rect, + /// Each group's members, rect and tab strip, top to bottom. + pub(super) groups: Vec<(Vec

, Rect, Rect)>, + /// The upper group's index and the band of each seam. + pub(super) seams: Vec<(usize, Rect)>, } -/// Names of every floating window resize handle, for focus classification. -pub(in crate::docking) const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; - impl Renderer<'_, P> { - pub(in crate::docking) fn floating(&mut self, ctx: &Context) { + pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating

]) { let screen = ctx.content_rect(); if !crate::docking::targets::usable(screen) { return; } let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); - let mut windows: Vec<_> = self.layout.floating.iter().collect(); + let mut windows: Vec<_> = windows.iter().collect(); windows.sort_by_key(|window| { let id = window.panels().next().map(|panel| self.id.with(("floating", panel))); layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) }); + #[cfg(feature = "native-windows")] + let windows = self.native_windows(ctx, windows); for window in windows { - let Some(first) = window.panels().next() else { continue }; - let [x, y, width, height] = window.rect; - let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 }); - let mut size = vec2(width, height).max(limits.min).min(limits.max); - if window.groups.iter().all(|group| group.minimized) { - size.y = size.y.min(window.groups.len() as f32 * self.style.tab_height); - } - let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); - let id = self.id.with(("floating", first)); - if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); - ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); - self.output.focused = self.activation.clone(); - } - egui::Area::new(id).fade_in(false).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { - ui.set_min_size(rect.size()); - let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); - ui.allocate_rect(rect, Sense::hover()); - // Opaque even when the host's panel colours are transparent. - ui.painter().rect_filled(rect, 0.0, self.style.floating_background); - self.stacked_groups(ui, &window.groups, rect, GroupKind::Floating { window: rect }); - ui.painter().rect_stroke(rect, 0.0, self.style.floating_stroke, egui::StrokeKind::Outside); - self.resize_handles(ui, window, id, rect, limits, screen); - }); + self.floating_window(ctx, window, screen); } } - /// Anchored resize from the side edges, the bottom edge and both bottom corners: the - /// opposite edges stay where they are. - fn resize_handles(&mut self, ui: &mut Ui, window: &Floating

, id: Id, rect: Rect, limits: PanelLimits, screen: Rect) { + /// Size limits of a floating window: its title bar, each group's strip and the bodies of + /// the groups that are not minimized. + pub(super) fn window_limits(&self, window: &Floating

) -> PanelLimits { + let mut limits = PanelLimits { min: vec2(0.0, self.style.title_height), max: vec2(1_000_000.0, self.style.title_height) }; + for (index, group) in window.groups.iter().enumerate() { + let policy = node_limits(&Node::Tabs { panels: group.panels.clone(), active: 0 }, self.style, &*self.content); + let gap = if index > 0 { GAP } else { 0.0 }; + limits.min.x = limits.min.x.max(policy.min.x); + limits.max.x = limits.max.x.min(policy.max.x); + let (min, max) = if group.minimized { (self.style.tab_height, self.style.tab_height) } else { (policy.min.y, policy.max.y) }; + limits.min.y += min + gap; + limits.max.y += max + gap; + } + let min = limits.min.min(vec2(1_000_000.0, 1_000_000.0)); + PanelLimits { min, max: limits.max.max(min).min(vec2(1_000_000.0, 1_000_000.0)) } + } + + fn floating_window(&mut self, ctx: &Context, window: &Floating

, screen: Rect) { let Some(first) = window.panels().next() else { return }; - for (name, handle, direction) in handles(rect) { - if !crate::docking::targets::usable(handle) { - continue; + let [x, y, width, height] = window.rect; + let limits = self.window_limits(window); + let size = vec2(width, height).max(limits.min).min(limits.max); + let rect = Rect::from_min_size(pos2(x, y), size); + let id = self.id.with(("floating", first)); + if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + ctx.move_to_top(FloatingFrame::layer(id)); + ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); + self.output.focused = self.activation.clone(); + } + let movable = window.panels().all(|panel| self.permissions(panel).movable); + let style = ChromeStyle::from_dock(self.style); + let resize_label = self.style.resize_window_label.clone(); + let response = FloatingFrame::new(id, rect) + .bounds(screen) + .limits(limits) + .resizable(movable) + .resize_label(&resize_label) + .show(ctx, &style, |ui, rect| self.window_contents(ui, window, rect, &style)); + for handle in &response.handles { + self.register_interaction(ctx, handle.id); + } + let down = ctx.input(|i| i.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(|h| h.id == id)) { + data.remove::(key); } - let cursor = if direction.x == 0.0 { - egui::CursorIcon::ResizeVertical - } else if direction.y == 0.0 { - egui::CursorIcon::ResizeHorizontal - } else if direction.x == direction.y { - egui::CursorIcon::ResizeNwSe - } else { - egui::CursorIcon::ResizeNeSw + }); + if let Some(resized) = response.resized { + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); + } + } + + /// The title bar, then the stacked groups below it. + pub(super) fn window_contents(&mut self, ui: &mut Ui, window: &Floating

, rect: Rect, style: &ChromeStyle) { + let title = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.title_height).min(rect.bottom()))); + self.title_bar(ui, window, title, rect, style); + let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max); + self.floating_stack(ui, &window.groups, body, rect); + } + + /// Drag moves the whole window live, double-click minimizes or expands every group, × + /// closes every panel in it. + fn title_bar(&mut self, ui: &mut Ui, window: &Floating

, title: Rect, rect: Rect, style: &ChromeStyle) { + let Some(first) = window.panels().next() else { return }; + let movable = window.panels().all(|p| self.permissions(p).movable); + let closable = window.panels().all(|p| self.permissions(p).closable); + let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); + let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable); + if closable { + bar = bar.close(&close_label); + } + let response = bar.show(ui, title, style); + self.register_interaction(ui.ctx(), response.bar.id); + if movable && response.bar.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(title.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Window, + members: window.panels().cloned().collect(), + origin, + source: rect, }; - let response = ui.interact(handle, id.with(name), Sense::drag()).on_hover_cursor(cursor); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); - let anchor_id = response.id.with("pointer-anchor"); - if response.drag_started() { - response.request_focus(); - if let Some(origin) = ui.input(|i| i.pointer.press_origin()) - && let Some(anchor) = AnchoredResize::new(rect, origin, direction) - { - ui.ctx().data_mut(|data| { - if let Some(previous) = data.get_temp::(self.id.with("pointer-resize")) { - data.remove::(previous.with("pointer-anchor")); - } - data.insert_temp(self.id.with("pointer-resize"), response.id); - data.insert_temp(anchor_id, anchor); - }); + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + if movable + && response.bar.dragged() + && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) + && drag.gesture == GroupGesture::Window + && let Some(pointer) = ui.ctx().pointer_interact_pos() + && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + { + // From the press origin, so fractional pointer steps do not accumulate rounding. + let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); + } + if response.bar.double_clicked() { + let minimized = !window.groups.iter().all(|g| g.minimized); + for group in window.groups.iter().filter(|g| g.minimized != minimized) { + if let Some(panel) = group.active_panel() { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized }); } } - let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); - let mut delta = Vec2::ZERO; - if response.has_focus() { - ui.memory_mut(|memory| { - memory.set_focus_lock_filter( - response.id, - egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, - ) - }); - ui.input_mut(|input| { - if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { - delta.x -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { - delta.x += 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { - delta.y -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { - delta.y += 8.0; - } - }); - } - let pointer_resize = response.dragged() || response.drag_stopped(); - let limits = PanelLimits { min: limits.min.max(vec2(40.0, self.style.tab_height + 20.0)).min(limits.max), ..limits }; - let next = if pointer_resize { - ui.ctx() - .data(|data| data.get_temp::(anchor_id)) - .and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, screen, limits))) - } else if delta != Vec2::ZERO { - AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, screen, limits)) - } else { - None - }; - let permitted = response.enabled() && window.panels().all(|panel| self.permissions(panel).movable); - if permitted - && !cancelled - && let Some(resized) = next.filter(|next| *next != rect) - { - self.output - .actions - .push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); - } - if response.drag_stopped() { - response.surrender_focus(); - } - if cancelled || !permitted || !ui.input(|input| input.pointer.primary_down()) { - ui.ctx().data_mut(|data| { - data.remove::(anchor_id); - if data.get_temp::(self.id.with("pointer-resize")) == Some(response.id) { - data.remove::(self.id.with("pointer-resize")); - } - }); - } - if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { - ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, self.style.accent)); + } + if let Some(close) = response.close { + self.register_interaction(ui.ctx(), close.id); + if close.clicked() { + self.output.actions.push(Action::CloseWindow { panel: first.clone() }); } } } + + /// Groups top to bottom with a bar between them. Minimized groups take their strip; the + /// others share the rest by their preferred heights, equally when none is set. + pub(super) fn floating_stack(&mut self, ui: &mut Ui, groups: &[Group

], body: Rect, window: Rect) { + let strip = self.style.tab_height; + let open = groups.iter().filter(|g| !g.minimized).count(); + let fixed = groups.len().saturating_sub(open) as f32 * strip + groups.len().saturating_sub(1) as f32 * GAP; + let available = (body.height() - fixed).max(0.0); + let equal = if open > 0 { available / open as f32 } else { 0.0 }; + let wanted = |g: &Group

| g.height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or(equal).max(1.0); + let total: f32 = groups.iter().filter(|g| !g.minimized).map(wanted).sum(); + let scale = if total > 0.0 { available / total } else { 0.0 }; + let mut hit = StackHit { window, groups: Vec::new(), seams: Vec::new() }; + let mut top = body.top(); + let mut rects = Vec::new(); + for (index, group) in groups.iter().enumerate() { + if index > 0 { + let gap = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + GAP).min(body.bottom()))); + ui.painter().rect_filled(gap, 0.0, self.style.tab_background); + hit.seams.push((index - 1, Rect::from_center_size(gap.center(), vec2(gap.width(), SEAM_BAND)))); + top = gap.bottom(); + } + let height = if group.minimized { strip } else { wanted(group) * scale }; + let rect = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + height).min(body.bottom()))); + self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Floating { window }); + let strip_rect = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + strip).min(rect.bottom()))); + hit.groups.push((group.panels.clone(), rect, strip_rect)); + rects.push(rect); + top = rect.bottom(); + } + // Dividers between two open groups resize both; registered after the strips, so their + // hit region wins at the shared edge. + for (index, pair) in groups.windows(2).enumerate() { + let ([upper, lower], Some(a), Some(b)) = (pair, rects.get(index), rects.get(index + 1)) else { continue }; + if upper.minimized || lower.minimized || scale <= 0.0 { + continue; + } + let (Some(upper_panel), Some(lower_panel)) = (upper.active_panel(), lower.active_panel()) else { continue }; + let divider = Rect::from_min_max(pos2(body.left(), a.bottom()), pos2(body.right(), b.top())); + let least = strip + self.style.min_pane; + let pair_size = a.height() + b.height(); + let (lo, hi) = if pair_size >= 2.0 * least { (least, pair_size - least) } else { (a.height(), a.height()) }; + let response = ResizeHandle::new( + self.id.with(("floating-divider", upper.panels.first())), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(a.height(), lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + if response.delta != 0.0 { + let next = (a.height() + response.delta).clamp(lo, hi); + self.output.actions.push(Action::ResizeGroup { panel: upper_panel.clone(), height: Some(next / scale) }); + self.output.actions.push(Action::ResizeGroup { panel: lower_panel.clone(), height: Some((pair_size - next) / scale) }); + } + } + self.frame.stacks.push(hit); + } + + /// Targets the group targets do not cover: the bands and seams of a floating stack (P3, + /// P4), then the layout's outer edge (P8). None leaves the pointer to the group targets. + /// `skip_own` looks through the window holding `members` (a window moving live). + pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option> { + let own = |panels: &[P]| panels.iter().any(|p| members.contains(p)); + let window = self.frame.stacks.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && w.groups.iter().any(|(g, ..)| own(g)))); + if let Some(window) = window { + let in_group = window.groups.iter().any(|(_, rect, _)| rect.contains(pointer)) || window.seams.iter().any(|(_, b)| b.contains(pointer)); + if !in_group { + // The title bar occludes what lies behind the window. + let anchor = members.first()?.clone(); + return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true }); + } + return self.stack_proposal(window, members, pointer); + } + let (anchor, zone, preview) = self.edge_proposal(members, pointer)?; + Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false }) + } + + fn stack_proposal(&self, hit: &StackHit

, members: &[P], pointer: Pos2) -> Option> { + let line = |y: f32| Rect::from_min_max(pos2(hit.window.left(), y - 1.0), pos2(hit.window.right(), y + 1.0)); + let same = |panels: &[P]| panels == members; + // An anchor in the group that is not itself moving. + let anchor = |panels: &[P]| panels.iter().find(|p| !members.contains(p)).cloned(); + for (upper, band) in &hit.seams { + if !band.contains(pointer) { + continue; + } + let (Some((a, rect, _)), Some((b, ..))) = (hit.groups.get(*upper), hit.groups.get(upper + 1)) else { return None }; + let line = line(rect.bottom() + GAP * 0.5); + if same(a) || same(b) { + let anchor = members.first()?.clone(); + return Some(DropProposal { anchor, placement: Placement::Group { below: true }, preview: Rect::NOTHING, insertion: None, noop: true }); + } + let (anchor, below) = match anchor(a) { + Some(panel) => (panel, true), + None => (anchor(b)?, false), + }; + return Some(DropProposal { anchor, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion: Some(line), noop: false }); + } + let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?; + if strip.contains(pointer) || same(panels) { + return None; + } + let band = rect.height() * 0.25; + let below = if pointer.y < rect.top() + band { + false + } else if pointer.y > rect.bottom() - band { + true + } else { + return None; + }; + let insertion = Some(line(if below { rect.bottom() } else { rect.top() })); + Some(DropProposal { anchor: anchor(panels)?, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion, noop: false }) + } + + /// What a group drag carries: the whole window rect for the only group of a floating + /// window, so floating it again keeps the window's size; else the group's rect. + pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect { + match owner.kind { + GroupKind::Floating { window } + if owner.panels.first().is_some_and(|p| self.layout.floating.iter().any(|w| w.groups.len() == 1 && w.contains(p))) => + { + window + } + _ => owner.rect, + } + } + + /// The action a whole-group or whole-window gesture emits for `to`. + pub(super) fn group_action(&self, drag: &GroupDrag

, to: Target

) -> Action

{ + match drag.gesture { + GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to }, + GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to }, + } + } + + /// Release of a title bar drag. The window already sits where it was dragged; a target + /// docks or merges every group in it, else hosts get the release in `group_drop`. + pub(super) fn drop_window(&mut self, drag: &GroupDrag

, pointer: Pos2, proposal: Option>) { + match proposal.filter(|p| !p.noop) { + Some(proposal) => { + let to = Target::At { anchor: proposal.anchor, placement: proposal.placement }; + self.output.actions.push(self.group_action(drag, to)); + } + None => self.output.group_drop = Some((drag.panel.clone(), pointer)), + } + } } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs new file mode 100644 index 00000000..8ad50589 --- /dev/null +++ b/crates/ui/src/docking/render/frame.rs @@ -0,0 +1,231 @@ +//! Frame profile: Maximize Frame, the outer edge band, hidden panels and the frame menu items. + +use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Pos2, Rect, pos2, vec2}; + +use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup}; +use crate::docking::{ + Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Permissions, Profile, Zone, + dock_model::{Place, node_at}, +}; + +/// What the renderer draws when it is not the layout itself, and the targets that depend on it. +pub(in crate::docking) struct FrameState

{ + /// View path to layout path of each split, when the view is not the layout. + paths: Option, Vec>>, + /// The view node whose rect is the outer edge area; none while a frame is maximized. + edge_path: Option>, + pub(super) edge: Option, + pub(super) maximized: Option

, + /// Floating stacks as drawn, back to front. + pub(super) stacks: Vec>, +} + +/// The layout as shown: a maximized frame alone, and hidden panels left out. +pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( + layout: &'a Layout

, + maximized: Option<&P>, + hide: HidePanels<'_, P>, + content: &dyn DockContent

, +) -> (Cow<'a, Layout

>, FrameState

) { + let protected = |p: &P| content.permissions(p) == Permissions::PROTECTED; + let hidden = |p: &P| match hide { + HidePanels::None => false, + HidePanels::All => !protected(p), + HidePanels::Except(keep) => !protected(p) && !keep(p), + }; + // Only a docked panel maximizes its frame. + let maximized = maximized.and_then(|panel| match layout.place(panel)? { + Place::Node(path) => Some((panel.clone(), path.clone(), layout.root.as_ref().and_then(|root| node_at(root, &path))?.clone())), + place @ Place::Dock { .. } => { + let group = layout.group(&place)?; + Some((panel.clone(), path_of(&place), Node::Tabs { panels: group.panels.clone(), active: group.active })) + } + Place::Floating { .. } => None, + }); + let mut state = FrameState { paths: None, edge_path: None, edge: None, maximized: None, stacks: Vec::new() }; + if maximized.is_none() && matches!(hide, HidePanels::None) { + state.edge_path = Some(layout.edge_path()); + return (Cow::Borrowed(layout), state); + } + let mut paths = HashMap::new(); + let view = match maximized { + Some((panel, path, node)) => { + state.maximized = Some(panel); + Layout { root: prune(&node, path, Vec::new(), &hidden, &mut paths), floating: Vec::new() } + } + None => { + let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths)); + let floating = layout + .floating + .iter() + .filter_map(|window| { + let groups: Vec<_> = window.groups.iter().filter_map(|g| prune_group(g, &hidden)).collect(); + (!groups.is_empty()).then(|| Floating { groups, rect: window.rect, native: window.native }) + }) + .collect(); + Layout { root, floating } + } + }; + if state.maximized.is_none() { + state.edge_path = Some(view.edge_path()); + } + state.paths = Some(paths); + (Cow::Owned(view), state) +} + +fn path_of(place: &Place) -> Vec { + match place { + Place::Node(path) | Place::Dock { path, .. } => path.clone(), + Place::Floating { .. } => Vec::new(), + } +} + +fn visible

(node: &Node

, hidden: &dyn Fn(&P) -> bool) -> bool { + match node { + Node::Split { first, second, .. } => visible(first, hidden) || visible(second, hidden), + Node::Tabs { panels, .. } => panels.iter().any(|p| !hidden(p)), + Node::Stack { entries } => entries.iter().any(|e| !hidden(&e.panel)), + Node::Dock { columns, .. } => columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).any(|p| !hidden(p)), + } +} + +/// `node` without hidden panels; a split with one visible side becomes that side. Records the +/// layout path of every split kept. +fn prune( + node: &Node

, + path: Vec, + at: Vec, + hidden: &dyn Fn(&P) -> bool, + paths: &mut HashMap, Vec>, +) -> Option> { + let child = |path: &[bool], side: bool| [path, &[side]].concat(); + match node { + Node::Split { axis, size, first, second } => match (visible(first, hidden), visible(second, hidden)) { + (true, true) => { + let first = prune(first, child(&path, false), child(&at, false), hidden, paths)?; + let second = prune(second, child(&path, true), child(&at, true), hidden, paths)?; + paths.insert(at, path); + Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) }) + } + (true, false) => prune(first, child(&path, false), at, hidden, paths), + (false, true) => prune(second, child(&path, true), at, hidden, paths), + (false, false) => None, + }, + Node::Tabs { panels, active } => { + let group = prune_group(&Group::new(panels.clone(), *active), hidden)?; + Some(Node::Tabs { panels: group.panels, active: group.active }) + } + Node::Stack { entries } => { + let entries: Vec<_> = entries.iter().filter(|e| !hidden(&e.panel)).cloned().collect(); + (!entries.is_empty()).then_some(Node::Stack { entries }) + } + Node::Dock { side, columns } => { + let columns: Vec<_> = columns + .iter() + .filter_map(|c| { + let groups: Vec<_> = c.groups.iter().filter_map(|g| prune_group(g, hidden)).collect(); + (!groups.is_empty()).then(|| Column { groups, ..c.clone() }) + }) + .collect(); + (!columns.is_empty()).then_some(Node::Dock { side: *side, columns }) + } + } +} + +/// A group without hidden panels; the selected panel stays selected when it is shown. +fn prune_group(group: &Group

, hidden: &dyn Fn(&P) -> bool) -> Option> { + let selected = group.active_panel(); + let panels: Vec

= group.panels.iter().filter(|p| !hidden(p)).cloned().collect(); + let active = selected.and_then(|s| panels.iter().position(|p| p == s)).unwrap_or(0); + (!panels.is_empty()).then(|| Group { panels, active, ..group.clone() }) +} + +impl Renderer<'_, P> { + pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) { + if self.frame.edge_path.as_deref() == Some(path) { + self.frame.edge = Some(rect); + } + } + + /// The layout path of a split drawn at view path `path`. + pub(super) fn original_path(&self, path: &[bool]) -> Vec { + self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec()) + } + + /// The outer edge band under the pointer (P8): top and bottom in both profiles, left and + /// right in the frame profile. Returns a docked anchor that is not moving, the zone and + /// the preview of the new frame. + pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> { + let area = self.frame.edge.filter(|area| area.contains(pointer))?; + let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0); + let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)]; + if self.style.profile == Profile::Frame { + edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]); + } + let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + if distance > band { + return None; + } + let anchor = self + .layout + .panels() + .into_iter() + .find(|p| !members.contains(p) && !matches!(self.layout.place(p), Some(Place::Floating { .. }) | None))? + .clone(); + let size = area.size() * 0.25; + let preview = match zone { + Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())), + Zone::Right => Rect::from_min_max(pos2(area.right() - size.x, area.top()), area.max), + Zone::Top => Rect::from_min_size(area.min, vec2(area.width(), size.y)), + Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(area.left(), area.bottom() - size.y), area.max), + }; + Some((anchor, zone, preview)) + } + + pub(super) fn float_label(&self) -> &str { + if self.style.profile == Profile::Frame { &self.style.undock_label } else { &self.style.float_label } + } + + pub(super) fn float_group_label(&self) -> &str { + if self.style.profile == Profile::Frame { &self.style.undock_group_label } else { &self.style.float_group_label } + } + + pub(super) fn close_group_label(&self) -> &str { + if self.style.profile == Profile::Frame { &self.style.close_panel_group_label } else { &self.style.close_group_label } + } + + /// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9). + pub(super) fn latch_undock(&self, ctx: &Context, kind: Option) { + let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd); + if self.style.profile == Profile::Frame && kind == Some(GroupKind::Frame) && command { + ctx.data_mut(|d| { + if let Some(drag) = d.get_temp::>(self.id.with("drag")) { + d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag }); + } + }); + } + } + + /// Frame items of the menu tail: Close Other Panels in Group and Maximize Frame (frame + /// profile), and Move Panel to New Window where native windows are available. + pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { + if self.style.profile == Profile::Frame { + let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable); + if owner.panels.len() > 1 && others && self.menu_action(ui, &self.style.close_others_label.clone()) { + self.output.actions.push(Action::CloseOthers { panel: panel.clone() }); + } + if owner.kind == GroupKind::Frame { + let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m)); + let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() }; + if self.menu_action(ui, &label) { + let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() }; + self.output.events.push(DockEvent::ToggleMaximize { panel }); + } + } + } + #[cfg(feature = "native-windows")] + self.new_window_item(ui, owner, panel); + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 3b80dfb3..b8f4ed8a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -26,8 +26,6 @@ pub(super) struct TabGroup<'a, P> { pub(super) rect: Rect, } -/// Width of a floating window's move grip at the left of each strip. -const GRIP_WIDTH: f32 = 12.0; /// The top band of a strip that drags the whole group. const BORDER_BAND: f32 = 4.0; @@ -65,13 +63,8 @@ impl Renderer<'_, P> { return; } let movable = panels.iter().all(|p| self.permissions(p).movable); - let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); + let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); ui.painter().rect_filled(strip, 0.0, self.style.tab_background); - if let GroupKind::Floating { window } = kind { - let grip = Rect::from_min_max(strip.min, pos2((strip.left() + GRIP_WIDTH).min(strip.right()), strip.bottom())); - self.window_grip(ui, panels, grip, window, movable); - strip.min.x = grip.right(); - } let floating = matches!(kind, GroupKind::Floating { .. }); let requested = self.content.group_header_width(panels, selected, floating); let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; @@ -149,38 +142,6 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } - /// The floating window's own move: the window follows the pointer, every group included. - fn window_grip(&mut self, ui: &mut Ui, panels: &[P], grip: Rect, window: Rect, movable: bool) { - let Some(first) = panels.first() else { return }; - let response = ui.interact(grip, self.id.with(("move-group", Some(first))), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - if movable && response.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); - let drag = GroupDrag { - panel: first.clone(), - original: [window.left(), window.top(), window.width(), window.height()], - gesture: GroupGesture::Window, - members: panels.to_vec(), - origin, - source: window, - }; - ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); - } - if movable - && response.dragged() - && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) - && drag.gesture == GroupGesture::Window - && let Some(pointer) = ui.ctx().pointer_interact_pos() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) - { - // From the press origin, so fractional pointer steps do not accumulate rounding. - let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); - } - ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); - } - /// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band /// (`drag-border`: drag only). Both move the whole group. fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) { @@ -194,7 +155,7 @@ impl Renderer<'_, P> { self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); if movable && response.drag_started() { - self.start_group_drag(ui, owner.panels, owner.rect); + self.start_group_drag(ui, owner.panels, self.drag_source(owner)); } if !bar { return; @@ -242,7 +203,7 @@ impl Renderer<'_, P> { let closable = all(&|p| p.closable); let movable = all(&|p| p.movable); let floatable = movable && all(&|p| p.floatable); - if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { + if closable && self.menu_action(ui, &self.close_group_label().to_owned()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } if owner.kind != GroupKind::Frame { @@ -251,7 +212,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); } } - if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label().to_owned()) { let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), @@ -277,11 +238,12 @@ impl Renderer<'_, P> { }); response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); } + self.frame_items(ui, owner, panel); } /// A stock menu button with shaped text and its logical accessibility label; true when /// clicked, after closing the menu. - fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + pub(super) fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), logical)); @@ -353,15 +315,16 @@ impl Renderer<'_, P> { { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } - if policy.movable && response.drag_started() { - begin_drag(ui.ctx(), self.id, panel.clone(), group); + if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { + self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind)); } response.context_menu(|ui| { self.content.panel_menu(ui, panel); if policy.movable && policy.floatable { - let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text); + let logical = self.float_label().to_owned(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); if response.clicked() { let size = self.float_size(panel, group.size()); let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 7539a1c0..fb299a07 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -7,6 +7,11 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; mod floating; +mod frame; +#[cfg(feature = "native-windows")] +mod native; + +pub(in crate::docking) use frame::{FrameState, view}; pub(in crate::docking) use group::GroupKind; @@ -26,6 +31,7 @@ pub(super) struct Renderer<'a, P> { pub(super) targets: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, + pub(super) frame: FrameState

, } impl Renderer<'_, P> { @@ -62,6 +68,7 @@ impl Renderer<'_, P> { if !crate::docking::targets::usable(rect) { return; } + self.note_edge(path, rect); match node { Node::Split { axis, size, first, second } => { let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; @@ -89,7 +96,7 @@ impl Renderer<'_, P> { if response.delta != 0.0 && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) { - self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); + self.output.actions.push(Action::ResizeSplit { path: self.original_path(path), size }); } } Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), @@ -266,12 +273,7 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont } pub(super) fn clamp_float(rect: Rect, screen: Rect) -> Rect { - if !crate::docking::targets::usable(screen) { - return rect; - } - let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size()); - let min = rect.min.max(screen.min).min(screen.max - size); - Rect::from_min_size(min, size) + crate::docking::chrome::clamp(rect, screen) } pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs new file mode 100644 index 00000000..05fe8196 --- /dev/null +++ b/crates/ui/src/docking/render/native.rs @@ -0,0 +1,154 @@ +//! Floating windows in their own OS windows (feature `native-windows`). A window with a native +//! rect is drawn with `show_viewport_immediate` where the backend has viewports; where egui +//! embeds viewports (wasm, tests) it stays in-window at its in-window rect. Tabs and groups do +//! not drag between OS windows. + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Pos2, Rect, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, vec2}; + +use super::{Renderer, clamp_float, group::TabGroup}; +use crate::docking::{Action, Drag, Floating, GroupDrag}; + +/// Smallest and largest native window, and the farthest it may sit from the desktop origin. +const MIN_SIZE: [f32; 2] = [160.0, 100.0]; +const MAX_SIZE: f32 = 16_384.0; +const MAX_POSITION: f32 = 100_000.0; + +/// A rect safe to hand to the windowing system: finite, its size and position bounded. +/// Hostile workspace files and bad monitor reports included. +pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] { + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let [x, y, w, h] = rect; + [ + finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION), + finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION), + finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE), + finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE), + ] +} + +/// The desktop rect a native viewport reports: outer position, inner size. None when the +/// backend reports nothing usable. +fn placed(outer: Option, inner: Option) -> Option<[f32; 4]> { + let (outer, inner) = (outer?, inner?); + (outer.min.is_finite() && inner.size().is_finite()).then(|| sanitized([outer.min.x, outer.min.y, inner.width(), inner.height()])) +} + +impl Renderer<'_, P> { + /// Show the native windows and return the windows to draw in-window. + pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { + let native = !ctx.embed_viewports(); + let mut shown = Vec::new(); + let mut in_window = Vec::new(); + for window in windows { + match window.native { + Some(rect) if native && window.panels().all(|p| self.content.allow_native(p)) => { + if let Some(id) = self.native_window(ctx, window, rect) { + shown.push(id); + } + } + _ => in_window.push(window), + } + } + // Forget where windows that are no longer native were told to open. + let key = self.id.with("native-shown"); + let previous = ctx.data_mut(|d| d.remove_temp::>(key)).unwrap_or_default(); + ctx.data_mut(|d| { + for id in previous.iter().filter(|id| !shown.contains(id)) { + d.remove::<[f32; 4]>(*id); + } + d.insert_temp(key, shown); + }); + in_window + } + + /// One floating window in its own viewport. Returns the key of its spawn rect. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4]) -> Option { + let first = window.panels().next()?.clone(); + let viewport = ViewportId::from_hash_of((self.id, "native", &first)); + // The window opens at its saved rect; after that the window system owns its position + // and size, which are read back below. + let spawn_key = self.id.with(("native-spawn", &first)); + let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_key, || sanitized(rect))); + let title = window.groups.first().and_then(|g| g.active_panel()).map(|p| self.label_of(p)).unwrap_or_default(); + let builder = ViewportBuilder::default() + .with_title(title) + .with_position([spawn[0], spawn[1]]) + .with_inner_size([spawn[2], spawn[3]]) + .with_min_inner_size(MIN_SIZE); + if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); + self.output.focused = self.activation.clone(); + } + let (targets, stacks) = (self.targets.len(), self.frame.stacks.len()); + let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| { + let rect = ui.max_rect(); + ui.painter().rect_filled(rect, 0.0, self.style.floating_background); + self.floating_stack(ui, &window.groups, rect, rect); + // An embedded fallback reads the main window's input: its close request and place + // are not this window's. + if matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { + ui.input(|i| (i.viewport().close_requested(), placed(i.viewport().outer_rect, i.viewport().inner_rect))) + } else { + (false, None) + } + }); + // Targets in another OS window are in its coordinates, and no drag crosses windows. + self.targets.truncate(targets); + self.frame.stacks.truncate(stacks); + ctx.data_mut(|d| { + if d.get_temp::>(self.id.with("drag")).is_some_and(|drag| window.contains(&drag.panel)) { + d.remove::>(self.id.with("drag")); + } + if d.get_temp::>(self.id.with("group-drag")).is_some_and(|drag| window.contains(&drag.panel)) { + d.remove::>(self.id.with("group-drag")); + } + }); + if closed { + ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_key)); + self.output.actions.push(Action::SetNative { panel: first, rect: None }); + return None; + } + if let Some(placed) = placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) { + self.output.actions.push(Action::SetNative { panel: first, rect: Some(placed) }); + } + Some(spawn_key) + } + + /// Move Panel to New Window: a floating panel's window, or the panel floated on its own. + pub(super) fn new_window_item(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { + let ctx = ui.ctx().clone(); + if ctx.embed_viewports() { + return; + } + let window = self.layout.floating.iter().find(|w| w.contains(panel)); + if window.is_some_and(|w| w.native.is_some()) { + return; + } + let movers: Vec

= window.map_or_else(|| vec![panel.clone()], |w| w.panels().cloned().collect()); + let allowed = |p: &P| self.content.allow_native(p) && self.permissions(p).floatable && self.permissions(p).movable; + if !movers.iter().all(allowed) { + return; + } + let in_window = match window { + Some(window) => window.rect, + None => { + let size = self.float_size(panel, owner.rect.size()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ctx.content_rect()); + [rect.left(), rect.top(), rect.width(), rect.height()] + } + }; + if !self.menu_action(ui, &self.style.new_window_label.clone()) { + return; + } + // Over the panel's place in the app window: its content's position on the desktop. + let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); + let [x, y, w, h] = in_window; + if window.is_none() { + self.output.actions.push(Action::Float { panel: panel.clone(), rect: in_window }); + } + let native = sanitized([origin.x + x, origin.y + y, w, h]); + self.output.actions.push(Action::SetNative { panel: panel.clone(), rect: Some(native) }); + } +} diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index de2aa445..45afb5de 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -58,22 +58,34 @@ impl Renderer<'_, P> { } let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { - GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)), + GroupGesture::Window => { + let proposal = if float_only { None } else { self.group_proposal(&drag, pointer) }; + self.drop_window(&drag, pointer, proposal); + } GroupGesture::Group => self.drop_group(ctx, &drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); - if drag.gesture == GroupGesture::Group { - self.paint_group_drag(ctx, &drag, pointer, float_only); - } + self.paint_group_drag(ctx, &drag, pointer, float_only); } } /// The valid target under the pointer for a whole group. A target holding the group - /// itself is a no-op proposal; a target the model would reject is no target. + /// itself is a no-op proposal; a target the model would reject is no target. A window + /// moving live looks through itself and does not split frames or join a body's centre. fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { - let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; - if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) { + let window = drag.gesture == GroupGesture::Window; + let valid = |proposal: DropProposal

| { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + (proposal.noop || self.layout.clone().apply_with_permissions(self.group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) + }; + if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { + return valid(proposal); + } + let own = |t: &crate::docking::DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); + let target = + self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?; + if own(target) { return Some(DropProposal { anchor: drag.panel.clone(), placement: Placement::Tab { before: None }, @@ -83,11 +95,10 @@ impl Renderer<'_, P> { }); } let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; - let action = Action::MoveGroup { - panel: drag.panel.clone(), - to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }, - }; - self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal) + if window && matches!(proposal.placement, Placement::Split(_)) { + return None; + } + valid(proposal) } /// Release of a group drag: join or split at the target, or float at the ghost with the @@ -117,18 +128,23 @@ impl Renderer<'_, P> { } } - /// Ghost outline at the pointer and one destination preview. + /// Ghost outline at the pointer (a window moving live is its own ghost) and one + /// destination preview. fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { let painter = self.preview_painter(ctx); - let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); - painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + if drag.gesture == GroupGesture::Group { + let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + } if float_only { return; } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { - let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center); + let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; - painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + if crate::docking::targets::usable(proposal.preview) { + painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + } if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } @@ -160,7 +176,7 @@ impl Renderer<'_, P> { } return; } - let float_only = float_only(ctx); + let float_only = float_only(ctx) || drag.undock; let floatable = self.permissions(&drag.panel).floatable && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); @@ -170,7 +186,11 @@ impl Renderer<'_, P> { } return; } - let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) }; + let proposal = if float_only { + None + } else { + self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| resolve_drop(&drag.panel, pointer, &self.targets)) + }; if released { cancel_drag::

(ctx, self.id); if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { @@ -199,7 +219,7 @@ impl Renderer<'_, P> { // Reordering inside the source strip shows only the insertion line. let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop { + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) { painter.rect( proposal.preview.shrink(1.0), 0.0, diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 901fc273..2c11c4cd 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -45,6 +45,32 @@ pub struct DockStyle { /// Painted under every floating window, so it stays opaque when the panel colours are not. pub floating_background: Color32, pub floating_stroke: Stroke, + /// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with + /// Maximize, Ctrl/Cmd-drag undock, splits on all four outer edges and frame menu labels. + pub profile: Profile, + /// Height of a floating window's title bar. + pub title_height: f32, + /// Width of the band along the layout's outer edge that splits at the root. + pub edge_band: f32, + pub move_window_label: String, + pub close_window_label: String, + /// Frame profile menu labels. + pub undock_label: String, + pub undock_group_label: String, + pub close_panel_group_label: String, + pub close_others_label: String, + pub maximize_label: String, + pub restore_frame_label: String, + /// Shown where native windows are available (feature `native-windows`). + pub new_window_label: String, +} + +/// Which arrangement a [`DockStyle`] renders and labels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Profile { + #[default] + Dock, + Frame, } impl DockStyle { @@ -77,11 +103,23 @@ impl DockStyle { drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, + profile: Profile::Dock, + title_height: super::chrome::TITLE_HEIGHT, + edge_band: 8.0, + move_window_label: "Move panel window".into(), + close_window_label: "Close panel window".into(), + undock_label: "Undock Panel".into(), + undock_group_label: "Undock Panel Group".into(), + close_panel_group_label: "Close Panel Group".into(), + close_others_label: "Close Other Panels in Group".into(), + maximize_label: "Maximize Frame".into(), + restore_frame_label: "Restore Frame Size".into(), + new_window_label: "Move Panel to New Window".into(), } } pub(super) fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width] + [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width, self.title_height, self.edge_band] .iter() .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 @@ -98,14 +136,41 @@ pub struct DockOutput

{ pub group_drag: Option

, /// Whole-group release for host-owned atomic docking policy. pub group_drop: Option<(P, Pos2)>, + /// Requests that are not layout actions. + pub events: Vec>, } impl

Default for DockOutput

{ fn default() -> Self { - Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None } + Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new() } } } +/// A request for host state outside the layout. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DockEvent

{ + /// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one + /// passed to [`DockArea::maximized`]. + ToggleMaximize { panel: P }, +} + +/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay. +pub enum HidePanels<'a, P> { + None, + All, + /// Every panel except those for which the function returns true. + Except(&'a dyn Fn(&P) -> bool), +} + +impl

Clone for HidePanels<'_, P> { + fn clone(&self) -> Self { + *self + } +} + +impl

Copy for HidePanels<'_, P> {} + /// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing /// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. #[derive(Clone, Copy, Debug, PartialEq)] @@ -139,6 +204,8 @@ pub(super) struct Drag

{ pub(super) origin: Pos2, pub(super) source: Rect, pub(super) grab: Vec2, + /// Ctrl/Cmd held at the press of a frame-profile tab drag: the drop always floats. + pub(super) undock: bool, } /// What a group gesture moves. @@ -171,7 +238,7 @@ pub fn begin_drag(ctx: &Context, area_id: Id, return false; } let grab = (origin - source_rect.min).clamp(Vec2::ZERO, source_rect.size()); - ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab })); + ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab, undock: false })); true } @@ -339,15 +406,31 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { /// Optional renderer for applications without an existing docking surface. Apps with custom /// chrome can instead use the model, target resolver and resize/tab primitives independently. -pub struct DockArea

{ +pub struct DockArea<'a, P = ()> { id: Id, activation: Option

, blockers: Vec, + maximized: Option

, + hide: HidePanels<'a, P>, } -impl DockArea

{ +impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new() } + Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None } + } + + /// Maximize Frame: the docked frame holding `panel` fills the area; other frames and + /// floating windows do not render or take input. A floating or absent panel is ignored. + pub fn maximized(mut self, panel: Option

) -> Self { + self.maximized = panel; + self + } + + /// Hide panels (Tab, Shift+Tab); their space goes to the panels that stay. Protected + /// panels always stay. The layout is unchanged. + pub fn hide_panels(mut self, hide: HidePanels<'a, P>) -> Self { + self.hide = hide; + self } /// Whether keyboard focus belongs to a rendered docking control in this layout. @@ -369,7 +452,11 @@ impl DockArea

{ || floating .panels() .next() - .is_some_and(|first| RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name)))) + .is_some_and(|first| { + RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) + || matches(self.id.with(("move-group", Some(first))).with("close")) + }) + || floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first())))) { return true; } @@ -420,7 +507,8 @@ impl DockArea

{ /// Render a validated snapshot and return requested actions for the app's command handler. /// No action is applied here. Panel contents get a global stable egui ID across moves. - /// Floating groups are egui areas inside this viewport; this does not create native windows. + /// Floating windows are egui areas inside this viewport; with feature `native-windows`, a + /// window with a native rect gets its own OS window where the backend has viewports. pub fn show( self, ui: &mut Ui, @@ -476,6 +564,7 @@ impl DockArea

{ cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } + let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); let mut renderer = Renderer { id: self.id, style, @@ -487,12 +576,13 @@ impl DockArea

{ targets: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), + frame, }; - if let Some(root) = &layout.root { + if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); ui.allocate_rect(rect, Sense::hover()); } - renderer.floating(ui.ctx()); + renderer.floating(ui.ctx(), &view.floating); renderer.finish_group_drag(ui.ctx()); renderer.finish_drag(ui.ctx()); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index cd5adafc..618ca268 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -904,7 +904,7 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() { assert!(out.actions.is_empty()); assert!(content.headers >= 3 && content.bodies >= 3); assert!(content.labels.iter().any(|s| s == "Logical 5")); - let at = pos2(130.0, 82.0); + let at = pos2(130.0, 96.0); custom_frame( &ctx, &layout, @@ -1249,9 +1249,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))), "custom action must paint: {logical}" ); - // This sample also has a floating-group grip labeled Move to group. - let expected = if logical == "Move to group" { 2 } else { 1 }; - assert_eq!(h.query_all_by_label(logical).count(), expected, "popup action must retain its own logical accessibility: {logical}"); + assert_eq!(h.query_all_by_label(logical).count(), 1, "popup action must retain its own logical accessibility: {logical}"); assert_eq!( h.query_all_by_label(&format!("Action visual: {logical}")).count(), 0, @@ -1368,7 +1366,7 @@ fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() { } ctx.memory_mut(|memory| memory.request_focus(id)); frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); - assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 26.0]); + assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 40.0]); } #[test] From e235c4c1832d53d87e9ce0ec50664a355c955405 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:21:35 +0000 Subject: [PATCH 4/5] docking: rustfmt and clippy for the floating and frame changes --- crates/ui/src/docking/chrome.rs | 25 ++++++++++++++----- crates/ui/src/docking/dock_model.rs | 3 +-- crates/ui/src/docking/floating_tests.rs | 12 ++++----- crates/ui/src/docking/render/floating.rs | 31 +++++++++++++++++------- crates/ui/src/docking/render/frame.rs | 12 ++++----- crates/ui/src/docking/render/group.rs | 6 ++--- crates/ui/src/docking/render/preview.rs | 3 ++- crates/ui/src/docking/surface.rs | 27 +++++++++++++-------- 8 files changed, 75 insertions(+), 44 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 2e2b14be..c8fe2ddd 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -66,7 +66,10 @@ fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { ), ( "resize-bottom-left", - Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2((rect.left() + 12.0).min(rect.right()), rect.bottom())), + Rect::from_min_max( + pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), + pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()), + ), vec2(-1.0, 1.0), ), ] @@ -133,7 +136,10 @@ impl<'a> FloatingFrame<'a> { pub fn show(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse { let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect()); let rect = clamp(self.rect, bounds); - let area = egui::Area::new(self.id).fade_in(false).order(Order::Middle).fixed_pos(rect.min) + let area = egui::Area::new(self.id) + .fade_in(false) + .order(Order::Middle) + .fixed_pos(rect.min) .default_size(rect.size()) .movable(false) .constrain_to(bounds); @@ -144,8 +150,7 @@ impl<'a> FloatingFrame<'a> { ui.painter().rect_filled(rect, 0.0, style.background); let inner = add_contents(ui, rect); ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); - let (resized, resize_started, handles) = - if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; + let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles } }); shown.inner @@ -185,10 +190,18 @@ impl<'a> FloatingFrame<'a> { let mut delta = Vec2::ZERO; if response.has_focus() { ui.memory_mut(|memory| { - memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + memory.set_focus_lock_filter( + response.id, + egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, + ) }); ui.input_mut(|input| { - for (key, step) in [(Key::ArrowLeft, vec2(-8.0, 0.0)), (Key::ArrowRight, vec2(8.0, 0.0)), (Key::ArrowUp, vec2(0.0, -8.0)), (Key::ArrowDown, vec2(0.0, 8.0))] { + for (key, step) in [ + (Key::ArrowLeft, vec2(-8.0, 0.0)), + (Key::ArrowRight, vec2(8.0, 0.0)), + (Key::ArrowUp, vec2(0.0, -8.0)), + (Key::ArrowDown, vec2(0.0, 8.0)), + ] { if input.consume_key(egui::Modifiers::NONE, key) { delta += step; } diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 05b3409f..7049543f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -526,8 +526,7 @@ impl Layout

{ let new = Node::Tabs { panels, active }; let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let (first, second, size) = - if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) }; + let (first, second, size) = if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) }; *node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) }; Ok(()) } diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 8d8dc758..b2782233 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -248,11 +248,7 @@ fn floating_stack_seams_and_bands_insert_groups() { let tab = d.tab(8).unwrap().center(); let seam = pos2(420.0, 237.0); let out = d.drag(tab, seam); - assert!( - out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), - "{:?}", - out.actions - ); + assert!(out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), "{:?}", out.actions); assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); // The divider between two open groups resizes both. @@ -496,7 +492,8 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() { let mut time = 0.0; let mut frame = |events: Vec| { time += 1.0 / 60.0; - let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() }; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() }; let mut result = None; done(ctx.run_ui(input, |ui| { let style = ChromeStyle::from_ui(ui); @@ -511,7 +508,8 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() { }; frame(vec![]); frame(vec![]); - let click = |frame: &mut dyn FnMut(Vec) -> ((bool, Vec2, bool, bool), Option), at| { + type Shown = ((bool, Vec2, bool, bool), Option); + let click = |frame: &mut dyn FnMut(Vec) -> Shown, at| { frame(vec![Event::PointerMoved(at), button(at, true)]); frame(vec![button(at, false)]) }; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 383a68b4..f40da2c8 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -74,12 +74,11 @@ impl Renderer<'_, P> { let movable = window.panels().all(|panel| self.permissions(panel).movable); let style = ChromeStyle::from_dock(self.style); let resize_label = self.style.resize_window_label.clone(); - let response = FloatingFrame::new(id, rect) - .bounds(screen) - .limits(limits) - .resizable(movable) - .resize_label(&resize_label) - .show(ctx, &style, |ui, rect| self.window_contents(ui, window, rect, &style)); + let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show( + ctx, + &style, + |ui, rect| self.window_contents(ui, window, rect, &style), + ); for handle in &response.handles { self.register_interaction(ctx, handle.id); } @@ -93,7 +92,9 @@ impl Renderer<'_, P> { } }); if let Some(resized) = response.resized { - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); + self.output + .actions + .push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); } } @@ -228,7 +229,13 @@ impl Renderer<'_, P> { if !in_group { // The title bar occludes what lies behind the window. let anchor = members.first()?.clone(); - return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true }); + return Some(DropProposal { + anchor, + placement: Placement::Tab { before: None }, + preview: Rect::NOTHING, + insertion: None, + noop: true, + }); } return self.stack_proposal(window, members, pointer); } @@ -249,7 +256,13 @@ impl Renderer<'_, P> { let line = line(rect.bottom() + GAP * 0.5); if same(a) || same(b) { let anchor = members.first()?.clone(); - return Some(DropProposal { anchor, placement: Placement::Group { below: true }, preview: Rect::NOTHING, insertion: None, noop: true }); + return Some(DropProposal { + anchor, + placement: Placement::Group { below: true }, + preview: Rect::NOTHING, + insertion: None, + noop: true, + }); } let (anchor, below) = match anchor(a) { Some(panel) => (panel, true), diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 8ad50589..d39235bc 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -184,16 +184,16 @@ impl Renderer<'_, P> { Some((anchor, zone, preview)) } - pub(super) fn float_label(&self) -> &str { - if self.style.profile == Profile::Frame { &self.style.undock_label } else { &self.style.float_label } + pub(super) fn float_label(&self) -> String { + if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() } } - pub(super) fn float_group_label(&self) -> &str { - if self.style.profile == Profile::Frame { &self.style.undock_group_label } else { &self.style.float_group_label } + pub(super) fn float_group_label(&self) -> String { + if self.style.profile == Profile::Frame { self.style.undock_group_label.clone() } else { self.style.float_group_label.clone() } } - pub(super) fn close_group_label(&self) -> &str { - if self.style.profile == Profile::Frame { &self.style.close_panel_group_label } else { &self.style.close_group_label } + pub(super) fn close_group_label(&self) -> String { + if self.style.profile == Profile::Frame { self.style.close_panel_group_label.clone() } else { self.style.close_group_label.clone() } } /// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9). diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index b8f4ed8a..fbdf35ae 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -203,7 +203,7 @@ impl Renderer<'_, P> { let closable = all(&|p| p.closable); let movable = all(&|p| p.movable); let floatable = movable && all(&|p| p.floatable); - if closable && self.menu_action(ui, &self.close_group_label().to_owned()) { + if closable && self.menu_action(ui, &self.close_group_label()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } if owner.kind != GroupKind::Frame { @@ -212,7 +212,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); } } - if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label().to_owned()) { + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) { let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), @@ -321,7 +321,7 @@ impl Renderer<'_, P> { response.context_menu(|ui| { self.content.panel_menu(ui, panel); if policy.movable && policy.floatable { - let logical = self.float_label().to_owned(); + let logical = self.float_label(); let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 45afb5de..a253a8bf 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -77,7 +77,8 @@ impl Renderer<'_, P> { let window = drag.gesture == GroupGesture::Window; let valid = |proposal: DropProposal

| { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; - (proposal.noop || self.layout.clone().apply_with_permissions(self.group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) + (proposal.noop || self.layout.clone().apply_with_permissions(self.group_action(drag, to), |p| self.permissions(p)).is_ok()) + .then_some(proposal) }; if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { return valid(proposal); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 2c11c4cd..b51bf258 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -119,9 +119,19 @@ impl DockStyle { } pub(super) fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width, self.title_height, self.edge_band] - .iter() - .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + [ + self.tab_height, + self.gap, + self.min_pane, + self.border.width, + self.font.size, + self.drag_bar_width, + self.floating_stroke.width, + self.title_height, + self.edge_band, + ] + .iter() + .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -449,13 +459,10 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { }; for floating in &layout.floating { if floating.groups.iter().any(|g| group(&g.panels)) - || floating - .panels() - .next() - .is_some_and(|first| { - RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) - || matches(self.id.with(("move-group", Some(first))).with("close")) - }) + || floating.panels().next().is_some_and(|first| { + RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) + || matches(self.id.with(("move-group", Some(first))).with("close")) + }) || floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first())))) { return true; From a842d330e0d9ef14bf860a7c0f1328f0d64e7dd8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:22:03 +0000 Subject: [PATCH 5/5] docking: test that hidden panels map split resizes to layout paths --- crates/ui/src/docking/floating_tests.rs | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index b2782233..fa486324 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -530,3 +530,32 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() { let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]); assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0)))); } + +#[test] +fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() { + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(200.0), + first: Box::new(tabs(&[9], 0)), + second: Box::new(Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2], 0)), + }), + }), + floating: Vec::new(), + }; + let mut d = Driver::new(layout, Profile::Frame); + d.hide = Hide::Keep; + d.content.keep = vec![1, 2]; + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others"); + let divider = d.response(Id::new(AREA).with(("split", &Vec::::new()))).unwrap().center(); + let out = d.hold(divider, divider + vec2(0.0, 60.0)); + assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions); + let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; + assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5))); +}