Dock: a panel dropped on a stacked panel becomes a tab in its frame, as in After Effects (#302)

A stack (the Default workspace's right column) held one panel per frame. Dropping a panel
onto the middle or the tab strip of a stacked panel inserted a new stacked frame below it,
and dropping on its top or bottom edge split the whole column.

A stacked frame now holds tabs (`StackEntry { panels, active, open, height }`):
- Center / tab strip drops add the panel as a tab of the anchor's frame and show it.
- Top / Bottom drops add a frame of its own above / below the anchor's frame only.
  (Left / Right still split the column: a frame can't hold a side-by-side split.)
- Clicking a frame's shown tab (or its header bar) expands / collapses it; another tab
  shows that panel. Closing a tab keeps the frame; its last tab removes it.
- Saved workspaces from before (`{"panel": …}` per frame) still load.

Tests: dock unit tests (Center / Top / Bottom on a stack, insert_tab, close, old saved
entries); a kittest dragging Effect Controls onto Properties makes a two-tab frame whose
tabs switch the shown panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 21:22:38 +00:00
1 parent ecb2cf1d32
commit 56fd5d52b1
3 files changed
+153 -32

No files matched your search

+130 -29
View File
@@ -168,15 +168,66 @@ pub enum SplitSize {
FixedB(f32),
}
/// One panel in a [`DockNode::Stack`].
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
/// One frame in a [`DockNode::Stack`]: a header with one tab per panel (a panel dropped onto a
/// stacked panel joins its frame as a tab, as in After Effects).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "StackEntryRepr")]
pub struct StackEntry {
pub panel: PanelKind,
pub panels: Vec<PanelKind>,
/// The shown tab.
pub active: usize,
pub open: bool,
/// Content height when open (set by dragging the gaps); `None` = share the remaining height.
pub height: Option<f32>,
}
/// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`.
#[derive(Deserialize)]
struct StackEntryRepr {
#[serde(default)]
panel: Option<PanelKind>,
#[serde(default)]
panels: Vec<PanelKind>,
#[serde(default)]
active: usize,
open: bool,
#[serde(default)]
height: Option<f32>,
}
impl From<StackEntryRepr> for StackEntry {
fn from(r: StackEntryRepr) -> StackEntry {
let mut panels = r.panels;
if let Some(p) = r.panel
&& !panels.contains(&p)
{
panels.insert(0, p);
}
let active = r.active.min(panels.len().saturating_sub(1));
StackEntry { panels, active, open: r.open, height: r.height }
}
}
impl StackEntry {
/// A frame showing just `panel`.
pub fn new(panel: PanelKind, open: bool, height: Option<f32>) -> StackEntry {
StackEntry { panels: vec![panel], active: 0, open, height }
}
pub fn contains(&self, p: PanelKind) -> bool {
self.panels.contains(&p)
}
/// The frame's shown panel.
pub fn shown(&self) -> Option<PanelKind> {
self.panels.get(self.active).or(self.panels.first()).copied()
}
/// Show `p` (one of its tabs).
fn show(&mut self, p: PanelKind) {
if let Some(i) = self.panels.iter().position(|x| *x == p) {
self.active = i;
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum DockNode {
/// `vertical`: children stacked top/bottom; else side by side.
@@ -202,7 +253,7 @@ fn tabs(p: &[PanelKind], active: usize) -> DockNode {
DockNode::Tabs { panels: p.to_vec(), active }
}
fn stack(e: &[(PanelKind, bool, Option<f32>)]) -> DockNode {
DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry { panel, open, height }).collect() }
DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry::new(panel, open, height)).collect() }
}
fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode {
DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) }
@@ -401,7 +452,7 @@ impl DockNode {
b.panels(out);
}
DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()),
DockNode::Stack { entries } => out.extend(entries.iter().map(|e| e.panel)),
DockNode::Stack { entries } => out.extend(entries.iter().flat_map(|e| e.panels.iter().copied())),
}
}
pub fn contains(&self, p: PanelKind) -> bool {
@@ -421,8 +472,9 @@ impl DockNode {
false
}
}
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) {
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) {
Some(e) => {
e.show(p);
e.open = true;
true
}
@@ -430,13 +482,15 @@ impl DockNode {
},
}
}
/// Expand or collapse a stacked panel. Returns false if `p` is not in a stack.
/// Expand or collapse a stacked panel's frame (showing `p`). Returns false if `p` is not in
/// a stack.
pub fn toggle_stacked(&mut self, p: PanelKind) -> bool {
match self {
DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p),
DockNode::Tabs { .. } => false,
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) {
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) {
Some(e) => {
e.show(p);
e.open = !e.open;
true
}
@@ -448,7 +502,7 @@ impl DockNode {
match self {
DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p),
DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p),
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p && e.open),
DockNode::Stack { entries } => entries.iter().any(|e| e.open && e.shown() == Some(p)),
}
}
/// Close a panel (remove its tab; empty groups collapse their split).
@@ -471,7 +525,12 @@ impl DockNode {
panels.retain(|x| *x != p);
*active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1)));
} else if let DockNode::Stack { entries } = self {
entries.retain(|e| e.panel != p);
for e in entries.iter_mut() {
let shown = e.shown();
e.panels.retain(|x| *x != p);
e.active = shown.and_then(|shown| e.panels.iter().position(|x| *x == shown)).unwrap_or(e.active.min(e.panels.len().saturating_sub(1)));
}
entries.retain(|e| !e.panels.is_empty());
}
}
/// Add a panel as a tab next to `near` (or into the first group).
@@ -492,9 +551,9 @@ impl DockNode {
false
}
}
DockNode::Stack { entries } => match entries.iter().position(|e| e.panel == near) {
DockNode::Stack { entries } => match entries.iter().position(|e| e.contains(near)) {
Some(i) => {
entries.insert(i + 1, StackEntry { panel: p, open: true, height: None });
entries.insert(i + 1, StackEntry::new(p, true, None));
true
}
None => false,
@@ -510,7 +569,7 @@ impl DockNode {
true
}
DockNode::Stack { entries } if !entries.is_empty() => {
entries.push(StackEntry { panel: p, open: true, height: None });
entries.push(StackEntry::new(p, true, None));
true
}
_ => false,
@@ -586,8 +645,10 @@ pub struct Floating {
impl DockNode {
/// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it
/// (Center) or in a new group split off to one side. `p` is removed from its old place
/// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone.
/// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s
/// frame: Center adds a tab to that frame and Top / Bottom add a frame above or below it
/// (#302); Left / Right split the column. `p` is removed from its old place first. Returns
/// false (tree unchanged) if `anchor` isn't docked or is `p` itself alone.
pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
if p == anchor || !self.contains(anchor) {
return false;
@@ -598,7 +659,7 @@ impl DockNode {
let here = match n {
DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone),
DockNode::Tabs { panels, .. } => panels.contains(&anchor),
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == anchor),
DockNode::Stack { entries } => entries.iter().any(|e| e.contains(anchor)),
};
if !here {
return false;
@@ -610,8 +671,16 @@ impl DockNode {
*active = i;
}
(Zone::Center, DockNode::Stack { entries }) => {
let i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1);
entries.insert(i, StackEntry { panel: p, open: true, height: None });
let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false };
let i = e.panels.iter().position(|x| *x == anchor).map_or(e.panels.len(), |i| i + 1);
e.panels.insert(i, p);
e.active = i;
e.open = true;
}
(Zone::Top | Zone::Bottom, DockNode::Stack { entries }) => {
let Some(i) = entries.iter().position(|e| e.contains(anchor)) else { return false };
let at = if zone == Zone::Top { i } else { i + 1 };
entries.insert(at, StackEntry::new(p, true, None));
}
(z, node) => {
let new = tabs(&[p], 0);
@@ -648,9 +717,13 @@ impl DockNode {
*active = i;
true
}
DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => {
let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len());
entries.insert(i, StackEntry { panel: p, open: true, height: None });
DockNode::Stack { entries } => {
// A tab of the anchor's frame.
let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false };
let i = before.and_then(|b| e.panels.iter().position(|x| *x == b)).unwrap_or(e.panels.len());
e.panels.insert(i, p);
e.active = i;
e.open = true;
true
}
_ => false,
@@ -670,7 +743,7 @@ impl DockNode {
match n {
DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))),
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()),
DockNode::Stack { entries } => entries.iter().position(|e| e.panel == p).map(|i| format!("{path}s{i}")),
DockNode::Stack { entries } => entries.iter().position(|e| e.contains(p)).map(|i| format!("{path}s{i}")),
}
}
rec(self, p, "")
@@ -886,7 +959,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
drag = Some((above, below, d));
}
}
out.push(Group { path: group, rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) });
out.push(Group { path: group, rect: r, content, panels: e.panels.clone(), active: e.active, stacked: Some(e.open) });
y = r.max.y + g;
}
if let Some((above, below, d)) = drag {
@@ -1184,7 +1257,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
}
}
if resp.clicked() {
if g.stacked.is_some() {
// A stacked frame's shown tab expands / collapses it; another tab shows that
// panel (expanding the frame).
if g.stacked.is_some() && (k == ai || entries.len() == 1) {
out.actions.push(DockAction::ToggleStacked(*p));
} else {
out.actions.push(activate(e));
@@ -1197,9 +1272,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
x += w + 8.0;
}
// A stacked panel's whole header bar expands/collapses it, not just its name (as in After
// Effects). A stack group holds one panel, so the rest of the strip is unambiguous.
// Effects): the frame with its shown tab.
if g.stacked.is_some()
&& let Some(e) = entries.first()
&& let Some(e) = entries.get(ai).or(entries.first())
{
let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max);
if rest.width() > 0.0 {
@@ -1269,7 +1344,7 @@ mod tests {
}
fn entry(panel: PanelKind, open: bool, height: Option<f32>) -> StackEntry {
StackEntry { panel, open, height }
StackEntry::new(panel, open, height)
}
fn close_to(a: &[f32], b: &[f32]) -> bool {
@@ -1471,16 +1546,42 @@ mod tests {
assert!(!d.dock_panel(Audio, Audio, Zone::Left));
assert!(!d.dock_panel(Audio, Tracker, Zone::Left));
assert_eq!(d, before);
// Into a stack.
// Into a stack: a tab of the anchor's frame (#302).
let mut s = workspace("Default");
let props = s.path_of(Properties).unwrap();
assert!(s.dock_panel(Tracker, Properties, Zone::Center));
assert!(s.path_of(Tracker).unwrap().contains('s'));
assert_eq!(s.path_of(Tracker), Some(props.clone()));
assert!(s.is_visible(Tracker) && !s.is_visible(Properties));
assert!(s.activate(Properties) && s.is_visible(Properties));
// Top / Bottom: a frame of its own above / below the anchor's, not a split column.
assert!(s.dock_panel(Info, Properties, Zone::Bottom));
let below = s.path_of(Info).unwrap();
assert_ne!(below, props);
assert_eq!(s.path_of(Properties), Some(props.clone()));
assert!(s.dock_panel(Info, Properties, Zone::Top));
assert_eq!(s.path_of(Info), Some(props.clone()), "above Properties: its frame's index");
// Closing a tab keeps the frame; closing the frame's last tab removes it.
s.close(Tracker);
assert!(s.contains(Properties) && !s.contains(Tracker));
// A tab dragged onto a stacked frame's tab strip joins that frame.
let mut t = workspace("Default");
assert!(t.insert_tab(Info, Preview, None));
assert_eq!(t.path_of(Info), t.path_of(Preview));
// Moving the only panel of a group collapses its split.
let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0));
assert!(m.dock_panel(Project, Composition, Zone::Center));
assert_eq!(m, tabs(&[Composition, Project], 1));
}
/// #302: workspaces saved when each stacked frame held one `panel` still load.
#[test]
fn old_stack_entries_load() {
let e: StackEntry = serde_json::from_str(r#"{"panel":"Preview","open":true,"height":46.0}"#).unwrap();
assert_eq!(e, StackEntry::new(PanelKind::Preview, true, Some(46.0)));
let s = serde_json::to_string(&workspace("Default")).unwrap();
assert_eq!(serde_json::from_str::<DockNode>(&s).unwrap(), workspace("Default"));
}
#[test]
fn floating_layout_round_trips() {
use PanelKind::*;
+1 -1
View File
@@ -47,7 +47,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
match n {
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()),
}
}
+22 -2
View File
@@ -64,7 +64,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
match n {
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()),
}
}
@@ -209,7 +209,7 @@ fn stacked_panel_gaps_resize_the_panels() {
match n {
DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)),
DockNode::Tabs { .. } => None,
DockNode::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height),
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.height),
}
}
let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties"));
@@ -345,3 +345,23 @@ fn a_stacked_panels_whole_header_bar_toggles_it() {
click_n(&mut h, menu.center(), 1);
assert!(open(&h), "the panel menu button doesn't toggle the panel");
}
/// #302: a panel dropped onto a stacked panel becomes a tab in that panel's frame (it was
/// inserted as a stacked panel of its own below it); its tabs switch the frame's panel.
#[test]
fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() {
let (mut h, _, _) = harness();
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
let fx = rect(&h, "panel.tab.EffectControls");
let props = rect(&h, "panel.Properties");
drag(&mut h, fx.center(), props.center());
assert_eq!(group_of(&dock(&h), PanelKind::Properties), Some(vec![PanelKind::Properties, PanelKind::EffectControls]));
assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties));
// The frame's other tab shows Properties again, the frame stays open.
let tab = rect(&h, "panel.tab.Properties");
click_n(&mut h, tab.center(), 1);
assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls));
// The layout (saved workspaces) round-trips.
let s = serde_json::to_string(&dock(&h)).unwrap();
assert_eq!(serde_json::from_str::<DockNode>(&s).unwrap(), dock(&h));
}