Skip to content

Commit d0f3214

Browse files
committed
Add interactive panel docking
1 parent 39656d4 commit d0f3214

File tree

8 files changed

+440
-39
lines changed

8 files changed

+440
-39
lines changed

editor/src/messages/portfolio/portfolio_message.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
2-
use super::utility_types::PanelGroupId;
2+
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
33
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
44
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
55
use crate::messages::portfolio::utility_types::FontCatalog;
@@ -61,6 +61,11 @@ pub enum PortfolioMessage {
6161
LoadDocumentResources {
6262
document_id: DocumentId,
6363
},
64+
MoveAllPanelTabs {
65+
source_group: PanelGroupId,
66+
target_group: PanelGroupId,
67+
insert_index: usize,
68+
},
6469
MovePanelTab {
6570
source_group: PanelGroupId,
6671
target_group: PanelGroupId,
@@ -146,6 +151,11 @@ pub enum PortfolioMessage {
146151
group: PanelGroupId,
147152
tab_index: usize,
148153
},
154+
SplitPanelGroup {
155+
target_group: PanelGroupId,
156+
direction: DockingSplitDirection,
157+
tabs: Vec<PanelType>,
158+
},
149159
SelectDocument {
150160
document_id: DocumentId,
151161
},

editor/src/messages/portfolio/portfolio_message_handler.rs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,54 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
460460
self.load_document(new_document, document_id, responses, false);
461461
responses.add(PortfolioMessage::SelectDocument { document_id });
462462
}
463+
PortfolioMessage::MoveAllPanelTabs {
464+
source_group,
465+
target_group,
466+
insert_index,
467+
} => {
468+
if source_group == target_group {
469+
return;
470+
}
471+
472+
let Some(source_state) = self.workspace_panel_layout.panel_group(source_group) else { return };
473+
let tabs: Vec<PanelType> = source_state.tabs.clone();
474+
if tabs.is_empty() {
475+
return;
476+
}
477+
478+
// Destroy layouts for all moved tabs and the displaced target tab
479+
for &panel_type in &tabs {
480+
Self::destroy_panel_layouts(panel_type, responses);
481+
}
482+
if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
483+
Self::destroy_panel_layouts(old_target_panel, responses);
484+
}
485+
486+
// Clear the source group
487+
if let Some(source) = self.workspace_panel_layout.panel_group_mut(source_group) {
488+
source.tabs.clear();
489+
source.active_tab_index = 0;
490+
}
491+
492+
// Insert all tabs into the target group
493+
if let Some(target) = self.workspace_panel_layout.panel_group_mut(target_group) {
494+
let index = insert_index.min(target.tabs.len());
495+
for (i, panel_type) in tabs.iter().enumerate() {
496+
target.tabs.insert(index + i, *panel_type);
497+
}
498+
target.active_tab_index = index;
499+
}
500+
501+
self.workspace_panel_layout.prune();
502+
503+
responses.add(MenuBarMessage::SendLayout);
504+
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
505+
506+
// Refresh the new active tab
507+
if let Some(panel_type) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
508+
self.refresh_panel_content(panel_type, responses);
509+
}
510+
}
463511
PortfolioMessage::MovePanelTab {
464512
source_group,
465513
target_group,
@@ -1222,6 +1270,37 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
12221270
}
12231271
}
12241272
}
1273+
PortfolioMessage::SplitPanelGroup { target_group, direction, tabs } => {
1274+
// Destroy layouts for the dragged tabs and the target group's active panel (it may get remounted by the frontend)
1275+
for &panel_type in &tabs {
1276+
Self::destroy_panel_layouts(panel_type, responses);
1277+
}
1278+
if let Some(target_active) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
1279+
Self::destroy_panel_layouts(target_active, responses);
1280+
}
1281+
1282+
// Remove the dragged tabs from their current panel groups (without pruning, so the target group survives)
1283+
for &panel_type in &tabs {
1284+
self.remove_panel_from_layout(panel_type);
1285+
}
1286+
1287+
// Create the new panel group adjacent to the target, then prune empty groups
1288+
let new_id = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone());
1289+
self.workspace_panel_layout.prune();
1290+
1291+
responses.add(MenuBarMessage::SendLayout);
1292+
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
1293+
1294+
// Refresh the new panel group's active tab
1295+
if let Some(panel_type) = self.workspace_panel_layout.panel_group(new_id).and_then(|g| g.active_panel_type()) {
1296+
self.refresh_panel_content(panel_type, responses);
1297+
}
1298+
1299+
// Refresh the target group's active panel since its component may have been remounted
1300+
if let Some(target_active) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
1301+
self.refresh_panel_content(target_active, responses);
1302+
}
1303+
}
12251304
PortfolioMessage::SelectDocument { document_id } => {
12261305
// Auto-save the document we are leaving
12271306
let mut node_graph_open = false;
@@ -1667,7 +1746,7 @@ impl PortfolioMessageHandler {
16671746
selected_nodes.first().copied()
16681747
}
16691748

1670-
/// Remove a dockable panel type from whichever panel group currently contains it, then prune empty groups.
1749+
/// Remove a dockable panel type from whichever panel group currently contains it. Does not prune empty groups.
16711750
fn remove_panel_from_layout(&mut self, panel_type: PanelType) {
16721751
// Save the panel's current position so it can be restored there later
16731752
self.workspace_panel_layout.save_panel_position(panel_type);
@@ -1678,8 +1757,6 @@ impl PortfolioMessageHandler {
16781757
group.tabs.retain(|&t| t != panel_type);
16791758
group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1));
16801759
}
1681-
1682-
self.workspace_panel_layout.prune();
16831760
}
16841761

16851762
/// Toggle a dockable panel on or off. When toggling off, refresh the newly active tab in its panel group (if any).
@@ -1689,6 +1766,7 @@ impl PortfolioMessageHandler {
16891766
let was_visible = self.workspace_panel_layout.panel_group(group_id).is_some_and(|g| g.is_visible(panel_type));
16901767
Self::destroy_panel_layouts(panel_type, responses);
16911768
self.remove_panel_from_layout(panel_type);
1769+
self.workspace_panel_layout.prune();
16921770

16931771
// If the removed panel was the active tab, refresh whichever panel is now active in that panel group
16941772
if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {

editor/src/messages/portfolio/utility_types.rs

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ impl From<String> for PanelType {
112112
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
113113
pub struct PanelGroupId(pub u64);
114114

115+
/// Which edge of a panel group to split on when docking a dragged panel.
116+
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
117+
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118+
pub enum DockingSplitDirection {
119+
Left,
120+
Right,
121+
Top,
122+
Bottom,
123+
}
124+
115125
/// State of a single panel group (leaf subdivision) in the workspace layout tree.
116126
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
117127
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -207,6 +217,30 @@ impl WorkspacePanelLayout {
207217
self.root.prune();
208218
}
209219

220+
/// Split a panel group by inserting a new panel group adjacent to it.
221+
/// The direction determines where the new group goes relative to the target.
222+
/// Left/Right creates a horizontal (row) split, Top/Bottom creates a vertical (column) split.
223+
/// Returns the ID of the newly created panel group.
224+
pub fn split_panel_group(&mut self, target_group_id: PanelGroupId, direction: DockingSplitDirection, tabs: Vec<PanelType>) -> PanelGroupId {
225+
let new_id = self.next_id();
226+
let new_group = SplitChild {
227+
subdivision: PanelLayoutSubdivision::PanelGroup {
228+
id: new_id,
229+
state: PanelGroupState { tabs, active_tab_index: 0 },
230+
},
231+
size: 50.,
232+
};
233+
234+
let insert_before = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Top);
235+
let needs_horizontal = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Right);
236+
237+
if !self.root.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0) {
238+
log::error!("Failed to insert split adjacent to panel group {target_group_id:?}: no matching-direction ancestor found");
239+
}
240+
241+
new_id
242+
}
243+
210244
/// Recalculate the default sizes for all splits in the tree based on document panel proximity.
211245
pub fn recalculate_default_sizes(&mut self) {
212246
self.root.recalculate_default_sizes();
@@ -409,23 +443,78 @@ impl PanelLayoutSubdivision {
409443
}
410444
}
411445

412-
/// Remove empty panel groups and collapse single-child splits.
446+
/// Remove empty panel groups and collapse unnecessary nesting.
447+
/// Does NOT collapse single-child splits into their child, as that would change subdivision depths
448+
/// and break the direction-by-depth alternation system.
413449
pub fn prune(&mut self) {
414-
if let PanelLayoutSubdivision::Split { children } = self {
415-
// Recursively prune children first
416-
children.iter_mut().for_each(|child| child.subdivision.prune());
450+
let PanelLayoutSubdivision::Split { children } = self else { return };
417451

418-
// Remove empty panel groups
419-
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()));
452+
// Recursively prune children
453+
children.iter_mut().for_each(|child| child.subdivision.prune());
420454

421-
// Remove empty splits (splits that lost all their children after pruning)
422-
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
455+
// Remove empty panel groups
456+
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()));
423457

424-
// If a split has exactly one child, replace this subdivision with that child's subdivision
425-
if children.len() == 1 {
426-
*self = children.remove(0).subdivision;
458+
// Remove empty splits (splits that lost all their children after pruning)
459+
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
460+
}
461+
462+
/// Check if this subtree contains a panel group with the given ID.
463+
pub fn contains_group(&self, target_id: PanelGroupId) -> bool {
464+
match self {
465+
PanelLayoutSubdivision::PanelGroup { id, .. } => *id == target_id,
466+
PanelLayoutSubdivision::Split { children } => children.iter().any(|child| child.subdivision.contains_group(target_id)),
467+
}
468+
}
469+
470+
/// Inserts a new split child adjacent to a target panel group and returns whether the insertion was successful.
471+
/// Recurses to the deepest split closest to the target that matches the requested split direction.
472+
/// If the target is a direct child of a mismatched-direction split, this wraps it in a new sub-split.
473+
pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize) -> bool {
474+
let PanelLayoutSubdivision::Split { children } = self else { return false };
475+
476+
let is_horizontal = depth.is_multiple_of(2);
477+
let direction_matches = is_horizontal == needs_horizontal;
478+
479+
// Find which child subtree contains the target
480+
let Some(containing_index) = children.iter().position(|child| child.subdivision.contains_group(target_id)) else {
481+
return false;
482+
};
483+
484+
// If the target is a direct child: we can certainly insert the new split, either as a sibling (if direction matches) or wrapping the target in a new split (if direction is mismatched)
485+
let target_is_direct_child = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id);
486+
if target_is_direct_child {
487+
// Direction matches and target is right here: insert as a sibling
488+
if direction_matches {
489+
let insert_index = if insert_before { containing_index } else { containing_index + 1 };
490+
children.insert(insert_index, new_child);
427491
}
492+
// Direction mismatch: wrap the target in a new sub-split (at depth+1, which has the opposite direction of this and thus is the requested direction)
493+
else {
494+
let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] });
495+
let old_child = SplitChild {
496+
subdivision: old_child_subdivision,
497+
size: 50.,
498+
};
499+
500+
if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision {
501+
if insert_before {
502+
sub_children.push(new_child);
503+
sub_children.push(old_child);
504+
} else {
505+
sub_children.push(old_child);
506+
sub_children.push(new_child);
507+
}
508+
}
509+
}
510+
511+
return true;
428512
}
513+
514+
// The target is deeper, so recurse into the containing child's subtree and return its insertion outcome
515+
children[containing_index]
516+
.subdivision
517+
.insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1)
429518
}
430519

431520
/// Check if this subtree contains the document panel.

0 commit comments

Comments
 (0)