Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion editor/src/messages/portfolio/portfolio_message.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
use super::utility_types::PanelGroupId;
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
use crate::messages::portfolio::utility_types::FontCatalog;
Expand Down Expand Up @@ -61,6 +61,11 @@ pub enum PortfolioMessage {
LoadDocumentResources {
document_id: DocumentId,
},
MoveAllPanelTabs {
source_group: PanelGroupId,
target_group: PanelGroupId,
insert_index: usize,
},
MovePanelTab {
source_group: PanelGroupId,
target_group: PanelGroupId,
Expand Down Expand Up @@ -146,6 +151,12 @@ pub enum PortfolioMessage {
group: PanelGroupId,
tab_index: usize,
},
SplitPanelGroup {
target_group: PanelGroupId,
direction: DockingSplitDirection,
tabs: Vec<PanelType>,
active_tab_index: usize,
},
SelectDocument {
document_id: DocumentId,
},
Expand Down
97 changes: 94 additions & 3 deletions editor/src/messages/portfolio/portfolio_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,59 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
self.load_document(new_document, document_id, responses, false);
responses.add(PortfolioMessage::SelectDocument { document_id });
}
PortfolioMessage::MoveAllPanelTabs {
source_group,
target_group,
insert_index,
} => {
if source_group == target_group {
return;
}

let Some(source_state) = self.workspace_panel_layout.panel_group(source_group) else { return };
let tabs: Vec<PanelType> = source_state.tabs.clone();
let source_active_tab_index = source_state.active_tab_index;
if tabs.is_empty() {
return;
}

// Validate that the target group exists before modifying the source
if self.workspace_panel_layout.panel_group(target_group).is_none() {
log::error!("Target panel group {target_group:?} not found");
return;
}

// Destroy layouts for all moved tabs and the displaced target tab
for &panel_type in &tabs {
Self::destroy_panel_layouts(panel_type, responses);
}
if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
Self::destroy_panel_layouts(old_target_panel, responses);
}

// Clear the source group
if let Some(source) = self.workspace_panel_layout.panel_group_mut(source_group) {
source.tabs.clear();
source.active_tab_index = 0;
}

// Insert all tabs into the target group, preserving which tab was active in the source
if let Some(target) = self.workspace_panel_layout.panel_group_mut(target_group) {
let index = insert_index.min(target.tabs.len());
target.tabs.splice(index..index, tabs.iter().copied());
target.active_tab_index = index + source_active_tab_index.min(tabs.len().saturating_sub(1));
}

self.workspace_panel_layout.prune();

responses.add(MenuBarMessage::SendLayout);
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);

// Refresh the new active tab
if let Some(panel_type) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
self.refresh_panel_content(panel_type, responses);
}
}
PortfolioMessage::MovePanelTab {
source_group,
target_group,
Expand Down Expand Up @@ -1222,6 +1275,45 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
}
}
PortfolioMessage::SplitPanelGroup {
target_group,
direction,
tabs,
active_tab_index,
} => {
// Destroy layouts for the dragged tabs and the target group's active panel (it may get remounted by the frontend)
for &panel_type in &tabs {
Self::destroy_panel_layouts(panel_type, responses);
}
if let Some(target_active) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
Self::destroy_panel_layouts(target_active, responses);
}

// Remove the dragged tabs from their current panel groups (without pruning, so the target group survives)
for &panel_type in &tabs {
self.remove_panel_from_layout(panel_type);
}

// Create the new panel group adjacent to the target, then prune empty groups
let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index) else {
log::error!("Failed to insert split adjacent to panel group {target_group:?}");
return;
};
self.workspace_panel_layout.prune();

responses.add(MenuBarMessage::SendLayout);
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);

// Refresh the new panel group's active tab
if let Some(panel_type) = self.workspace_panel_layout.panel_group(new_id).and_then(|g| g.active_panel_type()) {
self.refresh_panel_content(panel_type, responses);
}

// Refresh the target group's active panel since its component may have been remounted
if let Some(target_active) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
self.refresh_panel_content(target_active, responses);
}
}
PortfolioMessage::SelectDocument { document_id } => {
// Auto-save the document we are leaving
let mut node_graph_open = false;
Expand Down Expand Up @@ -1667,7 +1759,7 @@ impl PortfolioMessageHandler {
selected_nodes.first().copied()
}

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

self.workspace_panel_layout.prune();
}

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

// If the removed panel was the active tab, refresh whichever panel is now active in that panel group
if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {
Expand Down
107 changes: 96 additions & 11 deletions editor/src/messages/portfolio/utility_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ impl From<String> for PanelType {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct PanelGroupId(pub u64);

/// Which edge of a panel group to split on when docking a dragged panel.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DockingSplitDirection {
Left,
Right,
Top,
Bottom,
}

/// State of a single panel group (leaf subdivision) in the workspace layout tree.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
Expand Down Expand Up @@ -207,6 +217,26 @@ impl WorkspacePanelLayout {
self.root.prune();
}

/// Split a panel group by inserting a new panel group adjacent to it.
/// The direction determines where the new group goes relative to the target.
/// Left/Right creates a horizontal (row) split, Top/Bottom creates a vertical (column) split.
/// Returns the ID of the newly created panel group, or `None` if insertion failed.
pub fn split_panel_group(&mut self, target_group_id: PanelGroupId, direction: DockingSplitDirection, tabs: Vec<PanelType>, active_tab_index: usize) -> Option<PanelGroupId> {
let new_id = self.next_id();
let new_group = SplitChild {
subdivision: PanelLayoutSubdivision::PanelGroup {
id: new_id,
state: PanelGroupState { tabs, active_tab_index },
},
size: 50.,
};

let insert_before = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Top);
let needs_horizontal = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Right);

self.root.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0).then_some(new_id)
}

/// Recalculate the default sizes for all splits in the tree based on document panel proximity.
pub fn recalculate_default_sizes(&mut self) {
self.root.recalculate_default_sizes();
Expand Down Expand Up @@ -409,23 +439,78 @@ impl PanelLayoutSubdivision {
}
}

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

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

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

// If a split has exactly one child, replace this subdivision with that child's subdivision
if children.len() == 1 {
*self = children.remove(0).subdivision;
// Remove empty splits (splits that lost all their children after pruning)
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
}

/// Check if this subtree contains a panel group with the given ID.
pub fn contains_group(&self, target_id: PanelGroupId) -> bool {
match self {
PanelLayoutSubdivision::PanelGroup { id, .. } => *id == target_id,
PanelLayoutSubdivision::Split { children } => children.iter().any(|child| child.subdivision.contains_group(target_id)),
}
}

/// Inserts a new split child adjacent to a target panel group and returns whether the insertion was successful.
/// Recurses to the deepest split closest to the target that matches the requested split direction.
/// If the target is a direct child of a mismatched-direction split, this wraps it in a new sub-split.
pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize) -> bool {
let PanelLayoutSubdivision::Split { children } = self else { return false };

let is_horizontal = depth.is_multiple_of(2);
let direction_matches = is_horizontal == needs_horizontal;

// Find which child subtree contains the target
let Some(containing_index) = children.iter().position(|child| child.subdivision.contains_group(target_id)) else {
return false;
};

// 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)
let target_is_direct_child = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id);
if target_is_direct_child {
// Direction matches and target is right here: insert as a sibling
if direction_matches {
let insert_index = if insert_before { containing_index } else { containing_index + 1 };
children.insert(insert_index, new_child);
}
// 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)
else {
let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] });
let old_child = SplitChild {
subdivision: old_child_subdivision,
size: 50.,
};

if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision {
if insert_before {
sub_children.push(new_child);
sub_children.push(old_child);
} else {
sub_children.push(old_child);
sub_children.push(new_child);
}
}
}

return true;
}

// The target is deeper, so recurse into the containing child's subtree and return its insertion outcome
children[containing_index]
.subdivision
.insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1)
}

/// Check if this subtree contains the document panel.
Expand Down
24 changes: 0 additions & 24 deletions frontend/src/components/window/MainWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,6 @@

.workspace {
position: relative;
flex: 1 1 100%;

.workspace-grid-subdivision {
position: relative;
flex: 1 1 0;
min-height: 28px;

&.folded {
flex-grow: 0;
height: 0;
}
}

.workspace-grid-resize-gutter {
flex: 0 0 4px;

&.layout-row {
cursor: ns-resize;
}

&.layout-col {
cursor: ew-resize;
}
}
}

// Needed for the viewport hole punch on desktop
Expand Down
Loading
Loading