diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index dd85edd4..b5fc3bbd 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -18,6 +18,8 @@ serde = { version = "1", features = ["derive"], optional = true } serde = ["dep:serde"] # Floating windows may move to their own OS windows (desktop egui backends with viewports). native-windows = [] +# `docking::testing`: a headless driver for application docking tests. +testing = [] [dev-dependencies] egui = { workspace = true, features = ["default_fonts"] } diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs new file mode 100644 index 00000000..26c25d91 --- /dev/null +++ b/crates/ui/src/docking/hits.rs @@ -0,0 +1,124 @@ +//! Where each docking gesture starts, for control protocols and tests. + +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Id, Rect}; + +use super::{DockArea, Layout, Node, render::RESIZE_HANDLES}; + +/// A rendered docking control that starts a gesture. Groups, columns and windows are named by +/// their first panel. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum Hit

{ + /// A tab: click activates, drag moves the panel (S1). + Tab(P), + /// A group's drag bar: click opens the group menu, drag moves the group (S2). + DragBar(P), + /// The top border of a group's strip: drag moves the group (S3). + Border(P), + /// The "…" button of a group whose tabs overflow; named by the active panel. + Overflow(P), + /// [D] A column bar: drag moves the column (S6). + ColumnBar(P), + /// [D] The »/« button of a column bar (M2). + Collapse(P), + /// An accordion entry's −/+ button. + StackToggle(P), + /// A floating window's title bar (S5). + Title(P), + /// A floating window's × button. + Close(P), + /// [D] A panel's icon in an iconic column (S7, M5). + Icon(P), + /// [D] The grip above an icon group (S8). + IconGrip(P), + /// A floating window's resize handle. + Resize(P, ResizeEdge), + Divider(Divider

), +} + +/// The edge or corner a floating window's resize handle moves. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ResizeEdge { + Left, + Right, + Bottom, + BottomLeft, + BottomRight, +} + +/// A divider that resizes what is on either side of it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum Divider

{ + /// A split, by its path from the root (see [`super::Action::ResizeSplit`]). + Split(Vec), + /// Below the accordion entry of this panel. + Stack(P), + /// [D] The inner edge of the column holding this panel first. + Column(P), + /// Below the dock or floating group holding this panel first. + Group(P), +} + +fn key(area: Id) -> Id { + area.with("hit-rects") +} + +impl DockArea<'_, P> { + /// Every gesture source the area drew in its last frame, with its rect in points. Hosts + /// return these from a `ui.docking` control method beside the layout and `focused`; tests + /// drag from their centres. + pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit

, Rect)> { + ctx.data(|d| d.get_temp::, Rect)>>(key(id))).unwrap_or_default() + } +} + +/// Name the controls the renderer registered this frame and store their rects. +pub(super) fn record(ctx: &Context, area: Id, layout: &Layout

, owned: &[Id]) { + let mut names: HashMap> = HashMap::new(); + for panel in layout.panels() { + let p = || panel.clone(); + let some = Some(panel); + names.insert(area.with(("tab", panel)), Hit::Tab(p())); + names.insert(area.with(("icon", panel)), Hit::Icon(p())); + names.insert(area.with(("overflow", panel)), Hit::Overflow(p())); + names.insert(area.with(("collapse", panel)), Hit::StackToggle(p())); + names.insert(area.with(("stack-resize", panel)), Hit::Divider(Divider::Stack(p()))); + names.insert(area.with(("drag-bar", some)), Hit::DragBar(p())); + names.insert(area.with(("drag-border", some)), Hit::Border(p())); + names.insert(area.with(("icon-grip", some)), Hit::IconGrip(p())); + names.insert(area.with(("column-bar", some)), Hit::ColumnBar(p())); + names.insert(area.with(("column-collapse", some)), Hit::Collapse(p())); + names.insert(area.with(("column-resize", some)), Hit::Divider(Divider::Column(p()))); + names.insert(area.with(("group-resize", some)), Hit::Divider(Divider::Group(p()))); + names.insert(area.with(("floating-divider", some)), Hit::Divider(Divider::Group(p()))); + let title = area.with(("move-group", some)); + names.insert(title, Hit::Title(p())); + names.insert(title.with("close"), Hit::Close(p())); + let window = area.with(("floating", panel)); + let edges = [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft]; + for (name, edge) in RESIZE_HANDLES.iter().zip(edges) { + names.insert(window.with(*name), Hit::Resize(p(), edge)); + } + } + let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); + while let Some((node, path)) = nodes.pop() { + if let Node::Split { first, second, .. } = node { + names.insert(area.with(("split", &path)), Hit::Divider(Divider::Split(path.clone()))); + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + nodes.push((second, second_path)); + nodes.push((first, first_path)); + } + } + let hits: Vec<(Hit

, Rect)> = + owned.iter().filter_map(|id| Some((names.remove(id)?, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect(); + ctx.data_mut(|d| d.insert_temp(key(area), hits)); +} diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs new file mode 100644 index 00000000..67e722d2 --- /dev/null +++ b/crates/ui/src/docking/iconic_tests.rs @@ -0,0 +1,324 @@ +//! [D] Iconic columns, flyouts and hit rects, driven through `testing::DockDriver`. + +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Key, Pos2, Rect, pos2, vec2}; + +struct Content; + +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() } + } +} + +type Driver = DockDriver; + +/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0. +fn docked(groups: &[&[u32]], iconic: bool) -> Layout { + let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0); + column.iconic = iconic; + let dock = Node::Dock { side: Side::Start, columns: vec![column] }; + let canvas = Node::Tabs { panels: vec![0], active: 0 }; + Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }), + floating: Vec::new(), + } +} + +fn first_column(layout: &Layout) -> Column { + match &layout.root { + Some(Node::Split { first, .. }) => match first.as_ref() { + Node::Dock { columns, .. } => columns.first().cloned().unwrap(), + other => panic!("no dock: {other:?}"), + }, + other => panic!("no split: {other:?}"), + } +} + +fn groups(layout: &Layout) -> Vec> { + first_column(layout).groups.iter().map(|g| g.panels.clone()).collect() +} + +fn hit(d: &Driver, hit: Hit) -> Rect { + d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())) +} + +fn open(d: &Driver) -> Option { + flyout::(&d.ctx, d.id) +} + +fn texts(d: &Driver) -> Vec { + d.shapes() + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => Some(text.galley.text().to_owned()), + _ => None, + }) + .collect() +} + +const CANVAS: Pos2 = pos2(600.0, 400.0); + +#[test] +fn collapsed_column_shows_one_icon_per_panel_with_group_separators() { + let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3))); + let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3))); + assert_eq!(one.width(), 36.0, "the column takes the style's iconic width"); + assert_eq!((grip1.bottom(), one.bottom()), (one.top(), two.top()), "a group's icons follow its grip and each other"); + assert_eq!(grip3.top() - two.bottom(), 7.0, "groups are apart by the separator"); + assert_eq!(three.top(), grip3.bottom()); + assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs"); +} + +#[test] +fn widening_an_iconic_column_shows_labels() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + assert!(!texts(&d).iter().any(|t| t == "Panel 1")); + let edge = hit(&d, Hit::Divider(Divider::Column(1))); + let out = d.drag(edge.center(), pos2(150.0, edge.center().y)); + assert!(out.error.is_none()); + let width = first_column(&d.layout).iconic_width.unwrap(); + assert!((100.0..=160.0).contains(&width), "{width}"); + d.settle(); + let labels = texts(&d); + assert!(["Panel 1", "Panel 2", "Panel 3"].iter().all(|l| labels.iter().any(|t| t == l)), "{labels:?}"); + let edge = hit(&d, Hit::Divider(Divider::Column(1))); + d.drag(edge.center(), pos2(20.0, edge.center().y)); + d.settle(); + assert_eq!(first_column(&d.layout).iconic_width, Some(36.0)); + assert!(!texts(&d).iter().any(|t| t == "Panel 1")); +} + +#[test] +fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + let icon = hit(&d, Hit::Icon(2)).center(); + let out = d.click(icon); + assert!(out.actions.contains(&Action::Activate { panel: 2 })); + d.settle(); + assert_eq!(open(&d), Some(2)); + let column = hit(&d, Hit::Icon(1)); + let tab = hit(&d, Hit::Tab(2)); + assert!(tab.left() >= column.right(), "the flyout opens on the canvas side: {tab:?}"); + assert!(tab.top() <= hit(&d, Hit::IconGrip(1)).bottom(), "beside its group"); + assert_eq!(first_column(&d.layout).groups.first().map(|g| g.active), Some(1)); + + // Another icon switches flyouts, the same icon closes. + d.click(hit(&d, Hit::Icon(3)).center()); + d.settle(); + assert_eq!(open(&d), Some(3)); + assert!(d.hit(&Hit::Tab(2)).is_none()); + d.click(hit(&d, Hit::Icon(3)).center()); + d.settle(); + assert_eq!(open(&d), None); + + // Escape and a press outside close it. + d.click(icon); + d.settle(); + d.key(Key::Escape); + d.settle(); + assert_eq!(open(&d), None); + d.click(icon); + d.settle(); + let inside = hit(&d, Hit::Tab(2)).center(); + d.click(inside); + d.settle(); + assert_eq!(open(&d), Some(2), "a press inside keeps it"); + d.click(CANVAS); + d.settle(); + assert_eq!(open(&d), None); + + // Auto-Collapse off: it stays until an icon closes it. + d.auto_collapse = false; + d.click(icon); + d.settle(); + d.key(Key::Escape); + d.click(CANVAS); + d.settle(); + assert_eq!(open(&d), Some(2)); + d.click(icon); + d.settle(); + assert_eq!(open(&d), None); + + // U3: raising an iconic panel, as the Window menu does, opens its flyout. + d.raise = Some(3); + d.settle(); + assert_eq!(open(&d), Some(3)); +} + +#[test] +fn auto_collapse_toggle_is_a_column_menu_event() { + use egui_kittest::{Harness, kittest::Queryable}; + let layout = docked(&[&[1, 2]], true); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, events: &mut Vec>| { + let out = DockArea::new(egui::Id::new("menu")).show_content(ui, &layout, &DockStyle::from_ui(ui), &mut Content); + events.extend(out.events); + }, + Vec::new(), + ); + h.run_steps(2); + let bar = h.ctx.read_response(egui::Id::new("menu").with(("column-bar", Some(&1u32)))).unwrap().rect.center(); + h.input_mut().events.extend([ + egui::Event::PointerMoved(bar), + egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerButton { + pos: bar, + button: egui::PointerButton::Secondary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + h.get_by_label("Auto-Collapse Iconic Panels").click(); + h.run_steps(2); + assert_eq!(h.state(), &vec![DockEvent::ToggleAutoCollapse]); +} + +#[test] +fn dragging_a_flyout_strip_floats_its_group() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + d.click(hit(&d, Hit::Icon(1)).center()); + d.settle(); + let bar = hit(&d, Hit::DragBar(1)); + let out = d.drag(bar.center(), CANVAS); + assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 1, to: Target::Float { .. } }]), "{:?}", out.actions); + assert_eq!(groups(&d.layout), vec![vec![3]]); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::>()), Some(vec![1, 2])); + d.settle(); + assert_eq!(open(&d), None, "the flyout closes with its group gone"); + + // A tab dragged out of a flyout floats that panel alone. + d.click(hit(&d, Hit::Icon(3)).center()); + d.settle(); + let tab = hit(&d, Hit::Tab(3)); + let out = d.drag(tab.center(), pos2(450.0, 200.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); +} + +#[test] +fn icon_and_icon_group_grip_drags_move_a_panel_and_a_group() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + let before = d.layout.clone(); + // Escape, focus loss and cancel_drag end an icon drag with no change. + let icon = hit(&d, Hit::Icon(2)).center(); + d.hold(icon, CANVAS); + d.key(Key::Escape); + d.release(CANVAS); + d.hold(icon, CANVAS); + d.focus(false); + d.focus(true); + d.release(CANVAS); + d.hold(icon, CANVAS); + cancel_drag::(&d.ctx, d.id); + d.release(CANVAS); + assert_eq!(d.layout, before); + + let out = d.drag(icon, CANVAS); + let Some(Action::Float { panel: 2, rect }) = out.actions.first() else { panic!("{:?}", out.actions) }; + assert_eq!((rect[2], rect[3]), (240.0, 320.0), "a panel floated from its icon takes the column's expanded width"); + assert_eq!(groups(&d.layout), vec![vec![1], vec![3]]); + + let grip = hit(&d, Hit::IconGrip(3)).center(); + let out = d.drag(grip, pos2(450.0, 250.0)); + assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions); + assert_eq!(groups(&d.layout), vec![vec![1]]); +} + +#[test] +fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() { + let mut layout = docked(&[&[1], &[2]], true); + layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0])); + let mut d = DockDriver::new(layout, Content); + let bar = hit(&d, Hit::DragBar(5)).center(); + let one = hit(&d, Hit::Icon(1)); + let target = pos2(one.center().x, one.bottom() - 2.0); + d.hold(bar, target); + let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0)); + assert!(d.outlined(column.shrink(1.0)), "the column is outlined"); + let line = Rect::from_x_y_ranges(column.x_range(), (one.bottom() - 2.0)..=(one.bottom() + 2.0)); + assert!(d.filled(line), "with the insertion line under group 1"); + let out = d.release(target); + assert!( + out.actions.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true } } }), + "{:?}", + out.actions + ); + assert_eq!(groups(&d.layout), vec![vec![1], vec![5, 6], vec![2]]); + assert!(first_column(&d.layout).iconic); +} + +/// Every kind of gesture source has a rect, and dragging from it does something. +#[test] +fn hit_rects_cover_every_gesture_source() { + let mut expanded = Column::new(vec![Group::new(vec![1, 2, 3, 4, 5, 6, 7], 0), Group::new(vec![8], 0)], 150.0); + expanded.width = 150.0; + let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0); + icons.iconic = true; + let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] }; + let stack = Node::Stack { + entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }], + }; + let frames = Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![0], active: 0 }), + second: Box::new(stack), + }; + let mut window = Floating::single(vec![12], 0, [500.0, 60.0, 220.0, 300.0]); + window.groups.push(Group::new(vec![13], 0)); + let layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }), + floating: vec![window], + }; + let d = DockDriver::new(layout.clone(), Content); + let hits = d.hits(); + let kinds = |f: &dyn Fn(&Hit) -> bool| hits.iter().filter(|(h, _)| f(h)).count(); + let expected: [(&str, &dyn Fn(&Hit) -> bool); 15] = [ + ("tab", &|h| matches!(h, Hit::Tab(_))), + ("drag bar", &|h| matches!(h, Hit::DragBar(_))), + ("border", &|h| matches!(h, Hit::Border(_))), + ("overflow", &|h| matches!(h, Hit::Overflow(_))), + ("column bar", &|h| matches!(h, Hit::ColumnBar(_))), + ("collapse", &|h| matches!(h, Hit::Collapse(_))), + ("stack toggle", &|h| matches!(h, Hit::StackToggle(_))), + ("title", &|h| matches!(h, Hit::Title(12))), + ("close", &|h| matches!(h, Hit::Close(12))), + ("icon", &|h| matches!(h, Hit::Icon(9))), + ("icon grip", &|h| matches!(h, Hit::IconGrip(9))), + ("resize", &|h| matches!(h, Hit::Resize(12, _))), + ("split", &|h| matches!(h, Hit::Divider(Divider::Split(_)))), + ("column divider", &|h| matches!(h, Hit::Divider(Divider::Column(_)))), + ("group divider", &|h| matches!(h, Hit::Divider(Divider::Group(_)))), + ]; + for (name, kind) in expected { + assert!(kinds(kind) > 0, "{name}: {hits:?}"); + } + assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10))))); + assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 5); + for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) { + let mut d = DockDriver::new(layout.clone(), Content); + let from = rect.center(); + let moved = d.hold(from, from + vec2(-30.0, 40.0)); + let dragging = is_dragging::(&d.ctx, d.id); + let released = d.release(from + vec2(-30.0, 40.0)); + assert!(dragging || !moved.actions.is_empty() || !released.actions.is_empty(), "{hit:?} at {rect:?} does nothing"); + } + for (hit, rect) in hits.iter().filter(|(h, _)| matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) { + let mut d = DockDriver::new(layout.clone(), Content); + let out = d.click(rect.center()); + let opened = egui::Popup::is_any_open(&d.ctx); + assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing"); + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 6ddeebf6..85c76545 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -7,13 +7,18 @@ pub mod chrome; mod dock_model; +mod hits; mod model; mod render; mod resize; mod surface; mod targets; +#[cfg(any(test, feature = "testing"))] +pub mod testing; mod workspace; +pub use hits::{Divider, Hit, ResizeEdge}; +pub use render::{close_flyout, flyout, open_flyout}; 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::{ @@ -32,6 +37,8 @@ mod floating_tests; #[cfg(test)] mod group_tests; #[cfg(test)] +mod iconic_tests; +#[cfg(test)] mod model_tests; #[cfg(test)] mod tests; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 3d0f7e1a..fe5d4728 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -10,15 +10,11 @@ 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, Profile, Side, Target, + Action, Column, DockEvent, DockStyle, 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}; -/// Width of an iconic column without a saved width. -const ICONIC_WIDTH: f32 = 36.0; -/// Widest an iconic column gets by dragging its inner edge. -const ICONIC_MAX: f32 = 240.0; /// Width of the »/« button at the outer end of a column bar. const COLLAPSE_WIDTH: f32 = 18.0; @@ -44,8 +40,12 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side { } } -pub(in crate::docking) fn column_width

(column: &Column

) -> f32 { - if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH) } else { column.width } +pub(in crate::docking) fn column_width

(column: &Column

, style: &DockStyle) -> f32 { + if column.iconic { + column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) + } else { + column.width + } } /// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved @@ -104,7 +104,7 @@ impl Renderer<'_, P> { }; let gap = self.style.gap; let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width()); - let width = columns.iter().map(column_width).sum::().clamp(0.0, room); + let width = columns.iter().map(|c| column_width(c, self.style)).sum::().clamp(0.0, room); let (dock, rest) = match edge_of(side, self.style.right_to_left) { Edge::Left => ( Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())), @@ -141,12 +141,12 @@ impl Renderer<'_, P> { if usable(rect) { ui.painter().rect_filled(rect, 0.0, self.style.background); } - let total: f32 = columns.iter().map(column_width).sum(); + let total: f32 = columns.iter().map(|c| column_width(c, self.style)).sum(); let mut x = outer; let mut drawn = Vec::new(); for (index, column) in columns.iter().enumerate() { let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; - let width = column_width(column).min((rect.width() - (x - outer).abs()).max(0.0)); + let width = column_width(column, self.style).min((rect.width() - (x - outer).abs()).max(0.0)); let inner = x + inward * width; let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); self.column(ui, column, first, column_rect, edge); @@ -161,8 +161,8 @@ impl Renderer<'_, P> { } // Registered after the columns, so the narrow hit areas win at the edges. for (column, first, width, inner) in drawn { - let others = total - column_width(column); - let (lo, hi) = if column.iconic { (ICONIC_WIDTH, ICONIC_MAX) } else { self.column_bounds(column) }; + let others = total - column_width(column, self.style); + let (lo, hi) = if column.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(column) }; let hi = hi.min(room - others).max(lo.min(room - others).max(0.0)); let lo = lo.min(hi); let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range()); @@ -216,6 +216,9 @@ impl Renderer<'_, P> { let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); self.column_bar(ui, column, first, bar, rect, edge); let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + if column.iconic && usable(body) { + self.iconic_column(ui, column, body, edge); + } if column.iconic || !usable(body) { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); return; @@ -404,6 +407,9 @@ impl Renderer<'_, P> { if self.menu_action(ui, &label) { self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); } + if self.auto_collapse_item(ui) { + self.output.events.push(DockEvent::ToggleAutoCollapse); + } }); } @@ -411,6 +417,7 @@ impl Renderer<'_, P> { /// covers `pointer`. Covered dock targets take nothing. fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool { self.blockers.iter().any(|r| r.contains(pointer)) + || self.iconic.covers(pointer) || self.layout.floating.iter().any(|window| { let Some(first) = window.panels().next() else { return false }; !moving.is_some_and(|p| window.contains(p)) @@ -486,7 +493,9 @@ impl Renderer<'_, P> { /// The insertion line of a dock target. pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) { if usable(line) { - self.preview_painter(ctx).rect_filled(line, 1.0, self.style.accent); + let painter = self.preview_painter(ctx); + self.iconic.outline(&painter, line, self.style); + painter.rect_filled(line, 1.0, self.style.accent); } } } diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs new file mode 100644 index 00000000..738f0657 --- /dev/null +++ b/crates/ui/src/docking/render/iconic.rs @@ -0,0 +1,328 @@ +//! [D] Iconic columns: one icon per panel, groups apart, and the flyout an icon opens. +//! +//! Behaviour follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side, +//! names when the column is widened) and PhotoCraft `dock.rs` (Auto-Collapse Iconic Panels). + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{GroupKind, Renderer, dock::Edge}; +use crate::docking::{ + Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag, + chrome::{ChromeStyle, FloatingFrame}, + is_dragging, + targets::{DockDrop, DockTarget, horizontal_line, usable}, +}; + +/// Height of the grip above each icon group. +const GRIP: f32 = 6.0; +/// Room between two icon groups, with the separator line in its middle. +const SEPARATOR: f32 = 7.0; +/// Height of a flyout, or of a panel floated from an icon, when nothing else sizes it. +const FLYOUT_HEIGHT: f32 = 320.0; + +/// The panel whose icon opened the flyout of an area. +#[derive(Clone)] +struct Open

(P); + +fn key(area: Id) -> Id { + area.with("flyout") +} + +/// [D] Open the flyout of the group holding `panel`, when that group is in an iconic column. +/// The flyout shows from the next frame on. [`crate::docking::DockArea::raise_panel`] does the +/// same for a panel the Window menu opens. +pub fn open_flyout(ctx: &Context, area_id: Id, panel: P) { + ctx.data_mut(|d| d.insert_temp(key(area_id), Open(panel))); +} + +/// [D] Close the area's flyout. +pub fn close_flyout(ctx: &Context, area_id: Id) { + ctx.data_mut(|d| d.remove::>(key(area_id))); +} + +/// [D] The panel whose icon opened the area's flyout, while one is open. +pub fn flyout(ctx: &Context, area_id: Id) -> Option

{ + ctx.data(|d| d.get_temp::>(key(area_id))).map(|open| open.0) +} + +/// The flyout an iconic column asks for this frame. +struct Request

{ + group: Group

, + /// The icons of the group, where the flyout's top aligns. + icons: Rect, + /// The iconic column, which a press does not close the flyout in. + column: Rect, + edge: Edge, + /// The column's expanded width. + width: f32, +} + +/// Per-frame iconic state of a renderer. +pub(in crate::docking) struct Iconic

{ + auto_collapse: bool, + /// Iconic column bodies drawn this frame, outlined by a drop into them (P9). + columns: Vec, + /// Expanded column width of each panel drawn as an icon: a panel floated from its icon + /// takes it. + widths: Vec<(P, f32)>, + request: Option>, + /// The flyout drawn this frame; it occludes dock targets behind it. + rect: Option, +} + +impl Iconic

{ + pub(in crate::docking) fn new(auto_collapse: bool) -> Self { + Self { auto_collapse, columns: Vec::new(), widths: Vec::new(), request: None, rect: None } + } + + pub(super) fn covers(&self, pointer: Pos2) -> bool { + self.rect.is_some_and(|rect| rect.contains(pointer)) + } + + /// Outline the iconic column an insertion line lies in. + pub(super) fn outline(&self, painter: &egui::Painter, line: Rect, style: &DockStyle) { + if let Some(column) = self.columns.iter().find(|column| column.shrink(1.0).contains(line.center())) { + painter.rect_stroke(column.shrink(1.0), 0.0, Stroke::new(2.0, style.accent), StrokeKind::Inside); + } + } + + pub(super) fn float_width(&self, panel: &P) -> Option { + self.widths.iter().find(|(p, _)| p == panel).map(|(_, width)| *width) + } +} + +impl Renderer<'_, P> { + fn row_height(&self) -> f32 { + self.style.icon_size + 10.0 + } + + fn icon_group_height(&self, group: &Group

) -> f32 { + GRIP + group.panels.len() as f32 * self.row_height() + } + + /// The body of an iconic column: icon groups top to bottom, scrolling when they do not fit. + pub(super) fn iconic_column(&mut self, ui: &mut Ui, column: &Column

, body: Rect, edge: Edge) { + self.iconic.columns.push(body); + for panel in column.groups.iter().flat_map(|g| g.panels.iter()) { + self.iconic.widths.push((panel.clone(), column.width)); + } + // Opening a panel from the Window menu opens its flyout (U3). + if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) { + open_flyout(ui.ctx(), self.id, panel.clone()); + } + let total: f32 = column.groups.iter().map(|g| self.icon_group_height(g)).sum::() + + column.groups.len().saturating_sub(1) as f32 * SEPARATOR; + if total > body.height() + 0.5 { + let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return }; + let mut child = + ui.new_child(UiBuilder::new().id_salt(("icon-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(body.intersect(ui.clip_rect())); + egui::ScrollArea::vertical() + .id_salt(self.id.with(("icon-scroll", first))) + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL) + .show(&mut child, |ui| { + let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover()); + self.icon_groups(ui, column, area, body, edge); + }); + } else { + self.icon_groups(ui, column, body, body, edge); + } + } + + /// Icon groups in `area` (the scrolled content when the column scrolls), their drop bands + /// clipped to the visible `view`. + fn icon_groups(&mut self, ui: &mut Ui, column: &Column

, area: Rect, view: Rect, edge: Edge) { + let open = flyout::

(ui.ctx(), self.id); + let labels = view.width() >= self.style.iconic_label_width; + let xs = view.x_range(); + let mut top = area.top(); + let mut bands = Vec::new(); + for (index, group) in column.groups.iter().enumerate() { + let Some(first) = group.panels.first() else { continue }; + if index > 0 { + let y = top + SEPARATOR * 0.5; + ui.painter().with_clip_rect(view).hline(area.x_range().shrink(4.0), y, self.style.border); + top += SEPARATOR; + } + let rect = Rect::from_min_size(pos2(area.left(), top), vec2(area.width(), self.icon_group_height(group))); + let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP)); + self.icon_grip(ui, group, grip, column.width); + let showing = open.as_ref().filter(|p| group.panels.contains(p)); + let mut y = grip.bottom(); + for panel in &group.panels { + let cell = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), self.row_height())); + self.icon(ui, panel, cell, labels, showing); + y = cell.bottom(); + } + // The state again: a click above may have opened or closed this group's flyout. + if flyout::

(ui.ctx(), self.id).is_some_and(|p| group.panels.contains(&p)) { + self.iconic.request = Some(Request { group: group.clone(), icons: rect.intersect(view), column: view, edge, width: column.width }); + } + bands.push((first.clone(), rect)); + top = rect.bottom(); + } + // P9: the upper half of an icon group inserts above it, the lower half and the room + // under the last group below it. The line sits on the group's edge. + let count = bands.len(); + for (index, (anchor, rect)) in bands.into_iter().enumerate() { + let middle = rect.center().y; + let upper_top = if index == 0 { view.top() } else { rect.top() - SEPARATOR * 0.5 }; + let lower_bottom = if index + 1 == count { view.bottom() } else { rect.bottom() + SEPARATOR * 0.5 }; + for (below, ys, line) in [(false, upper_top..=middle, rect.top()), (true, middle..=lower_bottom, rect.bottom())] { + let band = Rect::from_x_y_ranges(xs, ys).intersect(view); + if usable(band) { + let line = horizontal_line(line.clamp(view.top() + 2.0, view.bottom() - 2.0), xs); + self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line }); + } + } + } + } + + /// The grip above an icon group drags the whole group (S8). + fn icon_grip(&mut self, ui: &mut Ui, group: &Group

, grip: Rect, width: f32) { + let Some(first) = group.panels.first() else { return }; + let movable = group.panels.iter().all(|p| self.permissions(p).movable); + let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag()); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); + let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text }; + let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect())); + painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y - 1.0, Stroke::new(1.0, color)); + painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color)); + 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 height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT); + let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); + let drag = GroupDrag { + panel: first.clone(), + original: [source.left(), source.top(), source.width(), source.height()], + gesture: GroupGesture::Group, + members: group.panels.clone(), + origin, + source, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + close_flyout::

(ui.ctx(), self.id); + } + } + + /// One panel's icon: a click opens, switches or closes the flyout (M5); a drag moves the + /// panel (S7). `showing` is the panel whose icon opened this group's flyout. + fn icon(&mut self, ui: &mut Ui, panel: &P, cell: Rect, labels: bool, showing: Option<&P>) { + let movable = self.permissions(panel).movable; + let label = self.label_of(panel); + let response = ui.interact(cell, self.id.with(("icon", panel)), if movable { Sense::click_and_drag() } else { Sense::click() }); + self.register_interaction(ui.ctx(), response.id); + let current = showing == Some(panel); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), current, &label)); + let painter = ui.painter().with_clip_rect(cell.intersect(ui.clip_rect())); + if current || response.hovered() { + painter.rect_filled(cell.shrink(2.0), 3.0, self.style.active_background); + } + if response.has_focus() { + painter.rect_stroke(cell.shrink(1.0), 3.0, Stroke::new(1.0, self.style.accent), StrokeKind::Inside); + } + let size = self.style.icon_size; + let inset = ((self.style.iconic_width - size) * 0.5).max(2.0); + let icon = Rect::from_min_size(pos2(cell.left() + inset, cell.center().y - size * 0.5), vec2(size, size)); + let color = if current { self.style.text } else { self.style.inactive_text }; + if let Some(source) = self.content.icon(panel) { + egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell)); + } else { + let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect(); + painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside); + painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color); + } + if labels { + let text = self.content.label(ui, panel, &label, &self.style.font, color); + let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone()); + painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color); + } else if response.hovered() && !response.dragged() { + response.clone().on_hover_text(&label); + } + if response.clicked() { + if current { + close_flyout::

(ui.ctx(), self.id); + } else { + if showing.is_none() { + ui.ctx().move_to_top(FloatingFrame::layer(self.id.with("flyout"))); + } + open_flyout(ui.ctx(), self.id, panel.clone()); + self.output.actions.push(crate::docking::Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + } + if movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), cell) { + close_flyout::

(ui.ctx(), self.id); + } + } + + /// The Auto-Collapse Iconic Panels item of a column menu (U1): true when chosen. + pub(super) fn auto_collapse_item(&mut self, ui: &mut Ui) -> bool { + let logical = self.style.auto_collapse_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let mut on = self.iconic.auto_collapse; + let response = ui.add(egui::Checkbox::new(&mut on, text)); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), self.iconic.auto_collapse, &logical)); + if response.clicked() { + ui.close(); + } + response.clicked() + } + + /// Draw the flyout an iconic column asked for, beside the column on the canvas side, or + /// forget a flyout whose group is no longer iconic. With Auto-Collapse, Escape or a press + /// outside the flyout and its column closes it. + pub(in crate::docking) fn flyout(&mut self, ctx: &Context) { + let Some(request) = self.iconic.request.take() else { + close_flyout::

(ctx, self.id); + return; + }; + let Some(active) = request.group.active_panel().cloned() else { return }; + let id = self.id.with("flyout"); + if self.iconic.auto_collapse && self.flyout_dismissed(ctx, id, request.column) { + close_flyout::

(ctx, self.id); + return; + } + let screen = ctx.content_rect(); + let bounds = self.workspace.intersect(screen); + let width = request.width.max(self.style.min_pane).min(bounds.width()); + let height = self + .content + .floating_size(&active) + .map(|size| size.y) + .or(request.group.height) + .filter(|h| h.is_finite() && *h > 0.0) + .unwrap_or(FLYOUT_HEIGHT) + .max(self.style.tab_height + self.style.min_pane) + .min(bounds.height()); + let left = match request.edge { + Edge::Left => request.column.right(), + Edge::Right => request.column.left() - width, + }; + let top = request.icons.top().min(bounds.bottom() - height).max(bounds.top()); + let rect = Rect::from_min_size(pos2(left, top), vec2(width, height)); + let style = ChromeStyle::from_dock(self.style); + let group = request.group; + let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| { + self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock); + }); + self.iconic.rect = Some(response.rect); + } + + /// Escape (outside a drag) or a primary press outside the flyout, its column and any menu. + fn flyout_dismissed(&self, ctx: &Context, id: Id, column: Rect) -> bool { + let escape = !is_dragging::

(ctx, self.id) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + let press = ctx.input(|i| i.pointer.primary_pressed().then(|| i.pointer.press_origin()).flatten()); + let outside = press.is_some_and(|pos| { + let inside = ctx.memory(|m| m.area_rect(id)).is_some_and(|r| r.contains(pos)) || column.contains(pos); + let menu = ctx.layer_id_at(pos).is_some_and(|layer| matches!(layer.order, Order::Foreground | Order::Tooltip)); + !inside && !menu + }); + escape || outside + } +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index b8179c37..613bf689 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -9,6 +9,7 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node mod dock; mod floating; mod frame; +mod iconic; #[cfg(feature = "native-windows")] mod native; @@ -16,6 +17,9 @@ pub(in crate::docking) use frame::{FrameState, view}; pub(in crate::docking) use group::GroupKind; +pub(in crate::docking) use iconic::Iconic; +pub use iconic::{close_flyout, flyout, open_flyout}; + pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; @@ -35,6 +39,8 @@ pub(super) struct Renderer<'a, P> { pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, pub(super) frame: FrameState

, + /// [D] Flyout and iconic column state for this frame. + pub(super) iconic: Iconic

, } impl Renderer<'_, P> { @@ -123,6 +129,7 @@ impl Renderer<'_, P> { self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None); let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); + self.register_interaction(ui.ctx(), response.id); response.widget_info(|| { egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, self.label_of(&entry.panel)) }); @@ -222,7 +229,7 @@ pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { match node { Node::Dock { columns, .. } => { - let width: f32 = columns.iter().map(dock::column_width).sum(); + let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum(); PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() } } Node::Tabs { panels, .. } => { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 6d982c88..734be291 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -269,7 +269,7 @@ impl Renderer<'_, P> { if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { return size; } - let mut size = source; + let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); if matches!(self.layout.place(panel), Some(Place::Dock { .. })) { size.y = size.y.min(DOCKED_FLOAT_HEIGHT); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 141f6f5d..8b6a4a77 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -59,6 +59,16 @@ pub struct DockStyle { pub seam_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] Side of a panel icon in an iconic column; each icon row is 10 pt taller. + pub icon_size: f32, + /// [D] Width of an iconic column without a saved width, and the narrowest it gets. + pub iconic_width: f32, + /// [D] Widest an iconic column gets by dragging its inner edge. + pub iconic_max_width: f32, + /// [D] An iconic column at least this wide shows panel names beside the icons. + pub iconic_label_width: f32, + /// [D] Column menu item that toggles Auto-Collapse Iconic Panels. + pub auto_collapse_label: String, /// [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, @@ -123,6 +133,11 @@ impl DockStyle { column_bar_height: 14.0, seam_band: 8.0, new_dock_band: 12.0, + icon_size: 20.0, + iconic_width: 36.0, + iconic_max_width: 240.0, + iconic_label_width: 96.0, + auto_collapse_label: "Auto-Collapse Iconic Panels".into(), right_to_left: false, move_column_label: "Move panel column".into(), collapse_to_icons_label: "Collapse to Icons".into(), @@ -153,9 +168,14 @@ impl DockStyle { self.column_bar_height, self.seam_band, self.new_dock_band, + self.icon_size, + self.iconic_width, + self.iconic_max_width, + self.iconic_label_width, ] .iter() .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + && self.iconic_width <= self.iconic_max_width && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -187,6 +207,9 @@ 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 }, + /// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its + /// preference and passes it to [`DockArea::auto_collapse_iconic`]. + ToggleAutoCollapse, } /// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay. @@ -448,11 +471,19 @@ pub struct DockArea<'a, P = ()> { blockers: Vec, maximized: Option

, hide: HidePanels<'a, P>, + auto_collapse: bool, } 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(), maximized: None, hide: HidePanels::None } + Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true } + } + + /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press + /// outside it. Off, it stays open until its icon or another icon is clicked. + pub fn auto_collapse_iconic(mut self, on: bool) -> Self { + self.auto_collapse = on; + self } /// Maximize Frame: the docked frame holding `panel` fills the area; other frames and @@ -620,14 +651,17 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { owned_interactions: Vec::new(), output: DockOutput::default(), frame, + iconic: super::render::Iconic::new(self.auto_collapse), }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); ui.allocate_rect(rect, Sense::hover()); } + renderer.flyout(ui.ctx()); renderer.floating(ui.ctx(), &view.floating); renderer.finish_group_drag(ui.ctx()); renderer.finish_drag(ui.ctx()); + super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); renderer.output } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs new file mode 100644 index 00000000..64702470 --- /dev/null +++ b/crates/ui/src/docking/testing.rs @@ -0,0 +1,222 @@ +//! Drive a [`DockArea`] with real egui input in application tests (feature `testing`). +//! +//! ```ignore +//! let mut driver = DockDriver::new(app_layout(), Panels::default()); +//! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap(); +//! driver.drag(bar.center(), egui::pos2(4.0, 300.0)); +//! assert!(driver.layout.dock_path(Side::Start).is_some()); +//! ``` + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, epaint::ClippedShape, vec2}; + +use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout}; + +/// Pointer travel that starts a drag before moving to its destination. +const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0); + +type StyleFn = Box; + +/// A [`DockArea`] in a headless egui context, with its layout and content. Each frame advances +/// the clock by 1/60 s, so double-clicks register. [`Self::step`] applies what a frame emits, +/// the way an application's command path would. +pub struct DockDriver { + pub ctx: Context, + pub layout: Layout

, + pub content: C, + /// The area's id, for [`DockArea::hit_rects`] and the free docking functions. + pub id: Id, + /// The window, 800x600 by default. + pub screen: Rect, + /// Where the area draws inside the window; the whole window by default. + pub area: Rect, + /// Whether the window has focus. Losing it cancels gestures (C2). + pub focused: bool, + /// Modifiers held for the following events. + pub modifiers: Modifiers, + /// Passed to [`DockArea::auto_collapse_iconic`]. + pub auto_collapse: bool, + /// Passed to [`DockArea::maximized`]. + pub maximized: Option

, + /// Passed to [`DockArea::raise_panel`] on the next frame only. + pub raise: Option

, + style: Option, + shapes: Vec, + time: f64, +} + +impl> DockDriver { + /// A driver whose first frames have settled, so every control has its rect. + pub fn new(layout: Layout

, content: C) -> Self { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut driver = Self { + ctx: Context::default(), + layout, + content, + id: Id::new("dock-driver"), + screen, + area: screen, + focused: true, + modifiers: Modifiers::NONE, + auto_collapse: true, + maximized: None, + raise: None, + style: None, + shapes: Vec::new(), + time: 0.0, + }; + driver.settle(); + driver + } + + /// Adjust the style each frame, after [`DockStyle::from_ui`]. Settles again. + pub fn with_style(mut self, style: impl Fn(&mut DockStyle) + 'static) -> Self { + self.style = Some(Box::new(style)); + self.settle(); + self + } + + /// Draw in `area` of the window. Settles again. + pub fn with_area(mut self, area: Rect) -> Self { + self.area = area; + self.settle(); + self + } + + /// Render one frame with `events` and return its output. Nothing is applied. + pub fn frame(&mut self, events: Vec) -> DockOutput

{ + let mut output = DockOutput::default(); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(self.screen), + events: std::iter::once(Event::ModifiersChanged(self.modifiers)).chain(events).collect(), + focused: self.focused, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); + let (style_fn, maximized, raise, auto) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse); + let mut full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + if let Some(adjust) = style_fn { + adjust(&mut style); + } + ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { + let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto); + if let Some(panel) = raise.clone() { + dock = dock.raise_panel(panel); + } + output = dock.show_content(ui, layout, &style, content); + }); + }); + full.textures_delta.clear(); + self.shapes = full.shapes; + output + } + + /// Render one frame and apply its actions, all or none, with the content's permissions. A + /// rejected batch is reported in the output's `error`. + pub fn step(&mut self, events: Vec) -> DockOutput

{ + let mut output = self.frame(events); + if output.error.is_none() + && let Err(error) = self.layout.apply_all(output.actions.clone(), |p| self.content.permissions(p)) + { + output.error = Some(error); + } + output + } + + /// Empty frames until moved widgets report their rects; egui reports them a frame late. + pub fn settle(&mut self) { + for _ in 0..3 { + self.step(Vec::new()); + } + } + + pub fn move_to(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos)]) + } + + pub fn press(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos), self.button(pos, true)]) + } + + pub fn release(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + + /// Press and release at `pos`; the output is the release frame's. + pub fn click(&mut self, pos: Pos2) -> DockOutput

{ + self.press(pos); + self.release(pos) + } + + /// Press at `from`, move past the drag threshold and on to `to`, without releasing. + pub fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput

{ + self.press(from); + self.move_to(from + NUDGE); + self.move_to(to) + } + + /// A whole drag; the output is the release frame's. + pub fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput

{ + self.hold(from, to); + self.release(to) + } + + /// Press and release `key` in one frame. + pub fn key(&mut self, key: Key) -> DockOutput

{ + let modifiers = self.modifiers; + self.step(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]) + } + + /// Give or take the window's focus for this and the following frames. + pub fn focus(&mut self, focused: bool) -> DockOutput

{ + self.focused = focused; + self.step(vec![Event::WindowFocused(focused)]) + } + + /// The pointer leaves the window. + pub fn leave(&mut self) -> DockOutput

{ + self.step(vec![Event::PointerGone]) + } + + /// Every gesture source of the last frame. + pub fn hits(&self) -> Vec<(Hit

, Rect)> { + DockArea::

::hit_rects(&self.ctx, self.id) + } + + /// The rect of one gesture source in the last frame. + pub fn hit(&self, hit: &Hit

) -> Option { + self.hits().into_iter().find(|(h, _)| h == hit).map(|(_, rect)| rect) + } + + /// The shapes of the last frame. + pub fn shapes(&self) -> &[ClippedShape] { + &self.shapes + } + + /// Whether the last frame filled `rect` (an insertion line or a split preview). + pub fn filled(&self, rect: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill != egui::Color32::TRANSPARENT, + _ => false, + }) + } + + /// Whether the last frame outlined `rect` without filling it. + pub fn outlined(&self, rect: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == egui::Color32::TRANSPARENT && shape.stroke.width > 0.0, + _ => false, + }) + } + + fn button(&self, pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: self.modifiers } + } +}