docking: showing a panel with no known place opens it in the first dock group or at an edge (#286)

* docking: open an unanchored panel in the first dock group or at an edge

* docking: an unanchored open prefers the end-side dock; an edge frame is a quarter wide

* docking: drop the redundant edge resize; an edge frame is already a quarter wide

* docking: an unanchored open passes over minimized groups and iconic columns
This commit is contained in:
aiongg authored and GitHub committed 2026-10-24 16:52:51 -04:00
1 parent 17744430be
commit 6fdd2efbba
6 files changed
+119 -4

No files matched your search

+2 -1
View File
@@ -501,7 +501,8 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// 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(
+24
View File
@@ -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")));
}
+42
View File
@@ -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;
+41
View File
@@ -365,6 +365,11 @@ pub enum Action<P> {
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<P>,
@@ -909,6 +914,13 @@ impl<P: Clone + Eq + Hash> Layout<P> {
/// application, particularly Open.
pub fn apply_with_permissions(&mut self, action: Action<P>, permissions: impl Fn(&P) -> Permissions) -> Result<Vec<P>, 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<P: Clone + Eq + Hash> Layout<P> {
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<P>)> {
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<P>, anchor: &P, placement: Placement<P>) -> Result<(), DockError> {
let place = self.place(anchor).ok_or(DockError::MissingTarget)?;
let tree = place.tree_path().map(|(window, path)| (window, path.to_vec()));
+3 -2
View File
@@ -120,7 +120,8 @@ impl<P: Clone + Eq + Hash> Workspace<P> {
}
/// 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<P>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> {
@@ -137,7 +138,7 @@ impl<P: Clone + Eq + Hash> Workspace<P> {
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;
+7 -1
View File
@@ -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.