diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 36b84bb8..99825f1d 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -501,7 +501,8 @@ impl DockCommand

{ /// Apply the command to `workspace`, all or nothing, and return the panels it closed so /// the application can release their state. Showing a hidden panel restores its last - /// location; Lock sets [`Workspace::locked`]. Application-owned commands + /// location, and a panel never laid out opens as [`Action::Open`] without an anchor does + /// ([`Workspace::set_visible`]); Lock sets [`Workspace::locked`]. Application-owned commands /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. /// `space` is where `window.panel.float` floats, as for [`Self::to_actions`]. pub fn apply( diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 913cb35d..78d406b1 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -420,3 +420,27 @@ fn dock_to_the_side_a_panel_already_leads_the_last_column_of() { // The whole column already is the last one: nothing to do. assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers", "column": true, "side": "Start"})), vec![]); } + +#[test] +fn showing_a_panel_never_laid_out_opens_it_in_the_first_dock_group_or_at_an_edge() { + // "brushes" is in neither the layout nor `hidden`. + for params in [ + json!({"command": "window.panel.activate", "panel": "brushes"}), + json!({"command": "window.panel.dock", "panel": "brushes"}), + json!({"command": "ui.dock", "operation": "open", "panel": "brushes"}), + ] { + let (shown, _) = applied(params.clone()); + assert!( + matches!(shown.layout.place(&s("brushes")), Some(dock_model::Place::Dock { column: 0, group: 0, .. })), + "{params}: {:?}", + shown.layout + ); + assert_eq!(shown.layout.panel_state(&s("brushes")), PanelState::Front, "{params}"); + } + // No dock, and the only frame is a protected canvas: a new frame at the edge, not a tab. + let mut workspace = Workspace::new(Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() }); + let command = parse(json!({"command": "window.panel.activate", "panel": "brushes"})).unwrap(); + command.apply(&mut workspace, false, &space(), permissions).unwrap(); + assert!(matches!(workspace.layout.place(&s("brushes")), Some(dock_model::Place::Node(_))), "{:?}", workspace.layout); + assert_ne!(workspace.layout.place(&s("brushes")), workspace.layout.place(&s("canvas"))); +} diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 1881135b..dcfd2b73 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -616,6 +616,48 @@ fn screen_reader_values_follow_the_column_width_on_either_edge() { } } +#[test] +fn an_unanchored_open_prefers_the_end_dock_and_an_edge_frame_takes_a_quarter() { + let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] }; + let end = docked(Side::End, &[&[&[2]]]).root.unwrap(); + let mut layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }), + floating: Vec::new(), + }; + layout.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + assert_eq!(columns(&layout, Side::End), vec![vec![vec![2, 9]]]); + assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1]]]); + // A minimized group and an iconic column are passed over: the panel would not show there. + let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] }; + let mut end = docked(Side::End, &[&[&[2]]]).root.unwrap(); + if let Node::Split { second, .. } = &mut end + && let Node::Dock { columns, .. } = second.as_mut() + { + columns[0].groups[0].minimized = true; + } + let mut layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }), + floating: Vec::new(), + }; + let mut iconic = layout.clone(); + layout.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1, 9]]]); + if let Some(Node::Split { first, .. }) = &mut iconic.root + && let Node::Dock { columns, .. } = first.as_mut() + { + columns[0].iconic = true; + } + iconic.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + assert!(matches!(iconic.place(&9), Some(dock_model::Place::Node(_))), "{iconic:?}"); + + // No dock: a frame at the right edge, a quarter of the width. + let mut layout = Layout { root: Some(Node::Tabs { panels: vec![0], active: 0 }), floating: Vec::new() }; + layout.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + let Some(Node::Split { axis: SplitAxis::Horizontal, size, second, .. }) = &layout.root else { panic!("{layout:?}") }; + assert_eq!(*size, SplitSize::Ratio(0.75)); + assert_eq!(**second, Node::Tabs { panels: vec![9], active: 0 }); +} + /// Panels 1-3 as a creative app's Color, Properties and Layers: Layers fills the column and asks /// for room for about ten rows. struct Creative; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index a30e5994..521ccd5d 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -365,6 +365,11 @@ pub enum Action

{ panel: P, }, /// Reveal a registered, currently absent panel. Applications validate their panel registry. + /// `anchor` names the group it joins as the last tab. Without one it becomes the root of an + /// empty layout, else joins the first dock group that accepts tabs and is shown (not + /// minimized, not in an iconic column; the end-side dock's first), else gets a new frame a + /// quarter wide at the right edge of the docked area + /// ([`Placement::Edge`]). Open { panel: P, anchor: Option

, @@ -909,6 +914,13 @@ impl Layout

{ /// application, particularly Open. pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.validate()?; + let action = match action { + Action::Open { panel, anchor: None } if self.root.is_some() && !self.contains(&panel) => { + let (anchor, placement) = self.open_place(&permissions).ok_or(DockError::MissingTarget)?; + Action::OpenAt { panel, anchor, placement } + } + action => action, + }; match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { // Opening an absent panel moves nothing, so it works in a locked workspace too. @@ -1241,6 +1253,35 @@ impl Layout

{ removed } + /// Where [`Action::Open`] without an anchor puts an absent panel in a layout with a root: + /// the end of the first shown dock group that accepts tabs, in the end-side dock if one has + /// such a group, else a new frame at the right edge of the docked area. + fn open_place(&self, permissions: impl Fn(&P) -> Permissions) -> Option<(P, Placement

)> { + let docked: Vec<(&P, Place)> = self + .panels() + .into_iter() + .filter_map(|p| Some((p, self.place(p)?))) + .filter(|(_, place)| !matches!(place, Place::Floating { .. })) + .collect(); + let side = |place: &Place| match place { + Place::Dock { path, .. } => match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { side, .. }) => Some(*side), + _ => None, + }, + _ => None, + }; + // A minimized group or an iconic column would leave the panel open but not shown. + let shown = |place: &Place| match place { + Place::Dock { group, .. } => self.column(place).is_some_and(|c| !c.iconic && c.groups.get(*group).is_some_and(|g| !g.minimized)), + _ => false, + }; + let groups = docked.iter().filter(|(p, place)| side(place).is_some() && shown(place) && permissions(p).accepts_tabs); + if let Some((panel, _)) = groups.clone().find(|(_, place)| side(place) == Some(Side::End)).or_else(|| groups.clone().next()) { + return Some(((*panel).clone(), Placement::Tab { before: None })); + } + docked.first().map(|(panel, _)| ((*panel).clone(), Placement::Edge(Zone::Right))) + } + fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { let place = self.place(anchor).ok_or(DockError::MissingTarget)?; let tree = place.tree_path().map(|(window, path)| (window, path.to_vec())); diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 244c5f0d..f5ab2885 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -120,7 +120,8 @@ impl Workspace

{ } /// Show or hide one panel. Showing restores its recorded location, following the chain of - /// hidden anchors when its own anchor is hidden too; otherwise it opens beside `fallback`. + /// hidden anchors when its own anchor is hidden too; otherwise it opens as + /// [`Action::Open`] with `fallback` as the anchor, following `permissions`. /// A shown panel becomes the active tab of its group. Hiding follows `permissions` and /// [`Self::locked`], as [`Self::apply`] does, so a protected canvas stays shown. pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { @@ -137,7 +138,7 @@ impl Workspace

{ let mut layout = self.layout.clone(); if self.restore_into(&mut layout, &panel).is_err() { layout = self.layout.clone(); - layout.apply(Action::Open { panel: panel.clone(), anchor: fallback })?; + layout.apply_with_permissions(Action::Open { panel: panel.clone(), anchor: fallback }, &permissions)?; } layout.apply(Action::Activate { panel: panel.clone() })?; self.layout = layout; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 2a6d48db..df9a61b2 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -72,6 +72,10 @@ layout.apply(Action::Move { - `Move`, `MovePanel`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a group, a dock column or a floating window, named by any panel in it. +- `Open` reveals an absent panel as the last tab of its `anchor`'s group. Without an anchor it + becomes the root of an empty layout, else joins the first dock group that accepts tabs and is + shown, not minimized or in an iconic column (the end-side dock's first), else gets a new frame a quarter wide at the right edge of the docked + area. `OpenAt` takes any placement. - A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`. - A `Placement` is a tab position, a split zone, a new group above or below (with an optional preferred `height`), a new column, or a new frame on an outer edge. @@ -109,7 +113,7 @@ an optional saved arrangement for Reset, and `locked`. | Method | Does | |---|---| | `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused; `SetNative`, which records the OS windows, still applies. | -| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else beside `fallback`. | +| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else as `Open` beside `fallback`, or where `Open` without an anchor puts it. | | `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | | `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | | `validate` | Check a loaded workspace before use. | @@ -316,6 +320,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan a new dock; when it already is that dock's last column, the command does nothing. - `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first. +- Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden + panel's place; a panel never laid out opens as `Open` without an anchor does. - `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see Floating size). It goes inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar.