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/chrome.rs b/crates/ui/src/docking/chrome.rs new file mode 100644 index 00000000..c8fe2ddd --- /dev/null +++ b/crates/ui/src/docking/chrome.rs @@ -0,0 +1,347 @@ +//! 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/dock_model.rs b/crates/ui/src/docking/dock_model.rs index d4200fe3..7049543f 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,23 @@ 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/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs new file mode 100644 index 00000000..fa486324 --- /dev/null +++ b/crates/ui/src/docking/floating_tests.rs @@ -0,0 +1,561 @@ +//! 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![]); + 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)]) + }; + 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)))); +} + +#[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))); +} 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 842dbb82..6ddeebf6 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; @@ -15,7 +16,10 @@ 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; @@ -24,6 +28,8 @@ use surface::{Drag, GroupDrag, GroupGesture}; #[cfg(test)] mod dock_tests; #[cfg(test)] +mod floating_tests; +#[cfg(test)] mod group_tests; #[cfg(test)] mod model_tests; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 92eeedbb..6800e565 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. @@ -572,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) { @@ -604,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 { @@ -970,7 +973,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); +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index c3e39966..3d0f7e1a 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p use super::{GroupKind, Renderer}; use crate::docking::{ - Action, Column, Group, GroupDrag, GroupGesture, Node, Side, Target, + Action, Column, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target, targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, }; use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; @@ -132,7 +132,7 @@ impl Renderer<'_, P> { if columns.is_empty() { let (band, line) = column_edge(outer, inward, rect.y_range()); let band = band.union(Rect::from_x_y_ranges( - outer.min(outer + inward * self.style.edge_band)..=outer.max(outer + inward * self.style.edge_band), + outer.min(outer + inward * self.style.new_dock_band)..=outer.max(outer + inward * self.style.new_dock_band), rect.y_range(), )); self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); @@ -418,16 +418,11 @@ impl Renderer<'_, P> { }) } - /// New docks are offered only where the dock profile is in use: in a layout with a dock. - fn has_dock(&self) -> bool { - self.layout.dock_path(Side::Start).is_some() || self.layout.dock_path(Side::End).is_some() - } - /// Dock targets, with the new-dock bands along the docked area's left and right edges that /// have no dock yet. fn dock_candidates(&self) -> Vec> { let mut candidates = self.dock_targets.clone(); - if !self.has_dock() || !usable(self.workspace) { + if self.style.profile != Profile::Dock || !usable(self.workspace) { return candidates; } let rtl = self.style.right_to_left; @@ -438,8 +433,8 @@ impl Renderer<'_, P> { continue; } let (x, inward) = match edge { - Edge::Left => (self.workspace.left(), self.style.edge_band), - Edge::Right => (self.workspace.right(), -self.style.edge_band), + Edge::Left => (self.workspace.left(), self.style.new_dock_band), + Edge::Right => (self.workspace.right(), -self.style.new_dock_band), }; let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys); let line = vertical_line(x + inward.signum() * 2.0, ys); @@ -496,10 +491,12 @@ impl Renderer<'_, P> { } } -/// The move a group gesture makes: the column for a column bar, else the group. +/// The move a group gesture makes: the column for a column bar, every group for a window's +/// title bar, else the group. pub(super) fn group_action(drag: &GroupDrag

, to: Target

) -> Action

{ match drag.gesture { GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to }, - GroupGesture::Window | GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to }, + GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to }, + GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to }, } } diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 43d18e25..becbd1dc 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -1,176 +1,317 @@ 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; + +/// 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(), self.style.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, + } + } + + /// Release of a title bar drag. The window already sits where it was dragged; a dock or + /// group target docks or merges every group in it, else hosts get the release in + /// `group_drop`. + pub(super) fn drop_window(&mut self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) { + self.output.actions.extend(action); + return; + } + let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; + match proposal.filter(|p| !p.noop) { + Some(proposal) => { + let to = Target::At { anchor: proposal.anchor, placement: proposal.placement }; + self.output.actions.push(super::dock::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..d39235bc --- /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) -> 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) -> 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) -> 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). + 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 3c5d33ce..08243920 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; use super::{Renderer, clamp_float, stripless}; -use crate::docking::{Action, DropTarget, Group, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; +use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; use crate::tabs; /// Where a group is drawn. It decides the drop targets the group offers and its gestures. @@ -26,28 +26,10 @@ 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; impl Renderer<'_, P> { - /// Groups stacked top to bottom. Minimized groups take their strip; the others share the - /// rest equally. - pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[Group

], rect: Rect, kind: GroupKind) { - 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; - let share = if open > 0 { (rect.height() - fixed).max(0.0) / open as f32 } else { 0.0 }; - let mut top = rect.top(); - for group in groups { - let bottom = (top + if group.minimized { strip } else { share }).min(rect.bottom()); - let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), bottom)); - self.group(ui, &group.panels, group.active, group.minimized, group_rect, kind); - top = bottom; - } - } - pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; let policy = self.permissions(selected); @@ -65,13 +47,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 +126,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 +139,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 +187,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()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } if owner.kind != GroupKind::Frame { @@ -254,7 +199,7 @@ impl Renderer<'_, P> { if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) { self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); } - 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()) { 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(), @@ -280,6 +225,7 @@ 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 @@ -356,15 +302,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(); + 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 3b16feed..b8179c37 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -8,6 +8,11 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node mod dock; 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; @@ -29,6 +34,7 @@ pub(super) struct Renderer<'a, P> { pub(super) dock_targets: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, + pub(super) frame: FrameState

, } impl Renderer<'_, P> { @@ -65,6 +71,7 @@ impl Renderer<'_, P> { if !crate::docking::targets::usable(rect) { return; } + self.note_edge(path, rect); match node { Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {} Node::Split { axis, size, first, second } => { @@ -93,7 +100,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), @@ -263,12 +270,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 010401de..6d982c88 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -58,22 +58,31 @@ 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 => self.drop_window(ctx, &drag, pointer, float_only), GroupGesture::Group | GroupGesture::Column => 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::Window { - 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. - 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)) { + /// 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. + pub(super) fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { + 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(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,8 +92,10 @@ impl Renderer<'_, P> { }); } let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; - let action = group_action(drag, 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,11 +128,14 @@ 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::Window { + 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; } @@ -132,9 +146,11 @@ impl Renderer<'_, P> { 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); } @@ -166,7 +182,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)); @@ -189,7 +205,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) { @@ -218,7 +238,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 d721c44d..141f6f5d 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -45,12 +45,20 @@ 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, /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. pub column_bar_height: f32, - /// [D] Height of the band around the seam between two stacked groups. + /// [D] Height of the band around the seam between two stacked groups, in docks and + /// floating windows. pub seam_band: f32, - /// [D] Width of the band along the window's left and right edges that makes a new dock. - pub edge_band: f32, + /// [D] Width of the band along the docked area's left and right edges that makes a new dock. + pub new_dock_band: f32, /// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide /// layout direction, so the host sets this from its locale. Frame splits keep their sides. pub right_to_left: bool, @@ -58,6 +66,25 @@ pub struct DockStyle { pub move_column_label: String, pub collapse_to_icons_label: String, pub expand_panels_label: String, + 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 { @@ -90,21 +117,45 @@ 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, column_bar_height: 14.0, seam_band: 8.0, - edge_band: 12.0, + new_dock_band: 12.0, right_to_left: false, move_column_label: "Move panel column".into(), collapse_to_icons_label: "Collapse to Icons".into(), expand_panels_label: "Expand Panels".into(), + 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] - .iter() - .chain(&[self.column_bar_height, self.seam_band, self.edge_band]) - .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, + self.column_bar_height, + self.seam_band, + self.new_dock_band, + ] + .iter() + .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -119,14 +170,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)] @@ -160,6 +238,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. @@ -194,7 +274,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 } @@ -362,15 +442,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. @@ -389,10 +485,11 @@ impl DockArea

{ }; 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)))) + || 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; } @@ -452,7 +549,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, @@ -508,6 +606,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, @@ -520,12 +619,13 @@ impl DockArea

{ dock_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]