Fix panel docking bugs and polish its behavior (#4087)

* Fix panel docking bugs and polish its behavior

* Fix bug
This commit is contained in:
Keavon Chambers
2026-05-01 17:02:10 +00:00
committed by Timon
parent 4c1974c200
commit 83d03ad67d
14 changed files with 417 additions and 255 deletions
@@ -58,12 +58,10 @@ impl MessageHandler<NewDocumentDialogMessage, ()> for NewDocumentDialogMessageHa
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(ViewportMessage::RepropagateUpdate);
responses.add(DocumentMessage::DeselectAllLayers);
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![
DocumentMessage::ZoomCanvasToFitAll.into(),
DocumentMessage::DeselectAllLayers.into(),
PortfolioMessage::AutoSaveActiveDocument.into(),
],
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into(), PortfolioMessage::AutoSaveActiveDocument.into()],
});
responses.add(DocumentMessage::MarkAsSaved);
@@ -29,6 +29,12 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageContext<'_>> for OverlaysMes
use crate::messages::viewport::{Position, ToPhysical};
use wasm_bindgen::JsCast;
// Discard detached canvas after a panel reorganization remounts the DOM
if self.canvas.as_ref().is_some_and(|canvas| !canvas.is_connected()) {
self.canvas = None;
self.context = None;
}
let canvas = match &self.canvas {
Some(canvas) => canvas,
None => {
@@ -195,10 +195,6 @@ pub enum PortfolioMessage {
UpdateOpenDocumentsList,
UpdateWorkspacePanelLayout,
ResetWorkspaceLayout,
ResetPanelGroupSizes {
/// Path of child indices from the root to the split node whose children's sizes should be reset to defaults.
split_path: Vec<usize>,
},
SetPanelGroupSizes {
/// Path of child indices from the root to the split node whose children's sizes are being set.
split_path: Vec<usize>,
@@ -453,6 +453,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
if let Some(layout) = state.workspace_layout {
self.workspace_panel_layout = layout;
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
// Refill panels whose content was lost when the layout load remounted their frontend components
for group_id in self.workspace_panel_layout.root.all_group_ids() {
if let Some(panel_type) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {
self.refresh_panel_content(panel_type, responses);
}
}
}
let PersistedState {
@@ -1330,13 +1337,16 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
Self::destroy_panel_layouts(target_active, responses);
}
// Preserve the source panel's visual weight at its new location
let source_slot_size = self.workspace_panel_layout.find_source_slot_size(&tabs);
// 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 {
let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index, source_slot_size) else {
log::error!("Failed to insert split adjacent to panel group {target_group:?}");
return;
};
@@ -1611,20 +1621,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
responses.add(MenuBarMessage::SendLayout);
}
PortfolioMessage::ResetPanelGroupSizes { split_path } => {
// Walk the tree to the target split node using the path
let mut node = &mut self.workspace_panel_layout.root;
for &index in &split_path {
let PanelLayoutSubdivision::Split { children } = node else { return };
let Some(child) = children.get_mut(index) else { return };
node = &mut child.subdivision;
}
// Recalculate default sizes for this split node
node.recalculate_default_sizes();
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
}
PortfolioMessage::SetPanelGroupSizes { split_path, sizes } => {
// Walk the tree to the target split node using the path
let mut node = &mut self.workspace_panel_layout.root;
@@ -1977,7 +1973,12 @@ impl PortfolioMessageHandler {
PanelType::Data => {
// The Data panel's content is populated automatically as a side effect of the graph run completing, so there's nothing to do here
}
PanelType::Document | PanelType::Welcome => {}
PanelType::Document | PanelType::Welcome => {
// Re-send the welcome screen buttons layout to repopulate after a remount
if self.document_ids.is_empty() {
responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout);
}
}
}
}
}
+191 -52
View File
@@ -2,6 +2,11 @@ use graphene_std::Color;
use graphene_std::raster::Image;
use graphene_std::text::{Font, FontCache};
/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels.
const DOCUMENT_PANEL_SHARE: f64 = 0.8;
/// Proportional share for each side when neither (or both) contain the document panel.
const EQUAL_PANEL_SHARE: f64 = 0.5;
#[derive(Debug, Default)]
pub struct CachedData {
pub font_cache: FontCache,
@@ -244,25 +249,54 @@ impl WorkspacePanelLayout {
/// 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> {
///
/// `source_slot_size` overrides the new panel's size (for moves where the old slot will be pruned).
/// If `None`, target's slot is split in the default ratio.
pub fn split_panel_group(
&mut self,
target_group_id: PanelGroupId,
direction: DockingSplitDirection,
tabs: Vec<PanelType>,
active_tab_index: usize,
source_slot_size: Option<f64>,
) -> 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.,
size: source_slot_size.unwrap_or(EQUAL_PANEL_SHARE),
};
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)
self.root
.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0, source_slot_size)
.then_some(new_id)
}
/// Find the slot size of the panel group whose entire content is exactly the given tabs.
/// Returns `None` if the tabs span multiple groups or don't fill their group exactly.
pub fn find_source_slot_size(&self, tabs: &[PanelType]) -> Option<f64> {
if tabs.is_empty() {
return None;
}
let group_id = self.find_panel(tabs[0])?;
if !tabs.iter().all(|&t| self.find_panel(t) == Some(group_id)) {
return None;
}
let group = self.panel_group(group_id)?;
if group.tabs.len() != tabs.len() {
return None;
}
self.root.find_slot_size_by_group_id(group_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();
self.root.recalculate_default_sizes_recursive();
}
/// Remember which panel group and tab index a panel was in before removal, so it can be restored there later.
@@ -311,10 +345,10 @@ impl WorkspacePanelLayout {
},
},
size: match panel_type {
PanelType::Data => 30.,
PanelType::Properties => 45.,
PanelType::Layers => 55.,
_ => 50.,
PanelType::Data => 0.3,
PanelType::Properties => 0.45,
PanelType::Layers => 0.55,
_ => EQUAL_PANEL_SHARE,
},
};
@@ -330,7 +364,10 @@ impl WorkspacePanelLayout {
if !matches!(&self.root, PanelLayoutSubdivision::Split { .. }) {
let old_root = std::mem::replace(&mut self.root, PanelLayoutSubdivision::Split { children: vec![] });
if let PanelLayoutSubdivision::Split { children } = &mut self.root {
children.push(SplitChild { subdivision: old_root, size: 80. });
children.push(SplitChild {
subdivision: old_root,
size: DOCUMENT_PANEL_SHARE,
});
}
}
@@ -340,7 +377,7 @@ impl WorkspacePanelLayout {
while root_children.len() <= root_child_index {
root_children.push(SplitChild {
subdivision: PanelLayoutSubdivision::Split { children: vec![] },
size: 20.,
size: (1. - DOCUMENT_PANEL_SHARE),
});
}
@@ -351,7 +388,7 @@ impl WorkspacePanelLayout {
if let PanelLayoutSubdivision::Split { children } = target {
children.push(SplitChild {
subdivision: old_subdivision,
size: 50.,
size: EQUAL_PANEL_SHARE,
});
}
}
@@ -386,10 +423,10 @@ impl Default for WorkspacePanelLayout {
active_tab_index: 0,
},
},
size: 100.,
size: 1.,
}],
},
size: 80.,
size: DOCUMENT_PANEL_SHARE,
},
SplitChild {
subdivision: PanelLayoutSubdivision::Split {
@@ -402,7 +439,7 @@ impl Default for WorkspacePanelLayout {
active_tab_index: 0,
},
},
size: 50.,
size: EQUAL_PANEL_SHARE,
},
SplitChild {
subdivision: PanelLayoutSubdivision::PanelGroup {
@@ -412,11 +449,11 @@ impl Default for WorkspacePanelLayout {
active_tab_index: 0,
},
},
size: 50.,
size: EQUAL_PANEL_SHARE,
},
],
},
size: 20.,
size: (1. - DOCUMENT_PANEL_SHARE),
},
],
},
@@ -427,6 +464,15 @@ impl Default for WorkspacePanelLayout {
}
}
/// The share of the slot that should go to the old side when splitting it with a new side.
fn document_split_share(old_side: &PanelLayoutSubdivision, new_side: &PanelLayoutSubdivision) -> f64 {
match (old_side.contains_document(), new_side.contains_document()) {
(true, false) => DOCUMENT_PANEL_SHARE,
(false, true) => 1. - DOCUMENT_PANEL_SHARE,
_ => EQUAL_PANEL_SHARE,
}
}
impl PanelLayoutSubdivision {
/// Find the panel group state for a given ID.
pub fn find_group(&self, target_id: PanelGroupId) -> Option<&PanelGroupState> {
@@ -463,9 +509,10 @@ impl PanelLayoutSubdivision {
}
}
/// 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.
/// Remove empty groups/splits and flatten single-child `Split`-in-`Split` nesting (which docking sequences can create).
///
/// Flattening preserves depth parity (and therefore direction). `PanelGroup`-only single-child splits are left
/// alone since collapsing would change the panel's depth and alter future wrap orientation.
pub fn prune(&mut self) {
let PanelLayoutSubdivision::Split { children } = self else { return };
@@ -477,6 +524,54 @@ impl PanelLayoutSubdivision {
// Remove empty splits (splits that lost all their children after pruning)
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
// Flatten single-child `Split`-in-`Split` nesting, rescaling sizes to preserve visual proportions
let mut i = 0;
while i < children.len() {
// Must be a `Split`...
let PanelLayoutSubdivision::Split { children: outer } = &children[i].subdivision else {
i += 1;
continue;
};
// ...with exactly one child...
let [only_child] = outer.as_slice() else {
i += 1;
continue;
};
// ...that is itself a `Split`
let PanelLayoutSubdivision::Split { .. } = &only_child.subdivision else {
i += 1;
continue;
};
// Remove the redundant wrapper
let removed = children.remove(i);
let outer_size = removed.size;
// Extract the inner grandchildren
let PanelLayoutSubdivision::Split { children: mut outer_children } = removed.subdivision else {
continue;
};
let Some(inner_split) = outer_children.pop() else { continue };
let PanelLayoutSubdivision::Split { children: inner_children } = inner_split.subdivision else {
continue;
};
// Splice grandchildren in at the same position, scaling their sizes to fill the removed slot
let inner_total: f64 = inner_children.iter().map(|c| c.size).sum();
for (offset, mut grandchild) in inner_children.into_iter().enumerate() {
grandchild.size = if inner_total > 0. { grandchild.size / inner_total * outer_size } else { outer_size };
children.insert(i + offset, grandchild);
}
}
// Renormalize to sum=1 since dock/prune cycles can compound shrinkage
let total: f64 = children.iter().map(|c| c.size).sum();
if total > 0. && (total - 1.).abs() > 0.001 {
for child in children.iter_mut() {
child.size /= total;
}
}
}
/// Remove all non-document/non-welcome tabs from panel groups, leaving only document-related panels.
@@ -503,7 +598,9 @@ impl PanelLayoutSubdivision {
/// 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 {
///
/// `source_slot_size` preserves the moved panel's visual weight. If `None`, uses the default split ratio.
pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize, source_slot_size: Option<f64>) -> bool {
let PanelLayoutSubdivision::Split { children } = self else { return false };
let is_horizontal = depth.is_multiple_of(2);
@@ -517,18 +614,35 @@ impl PanelLayoutSubdivision {
// 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
// Direction matches: insert as sibling, sizing based on whether target will be pruned, source size hint, or default ratio
if direction_matches {
let mut new_child = new_child;
let target_will_be_pruned = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty());
if target_will_be_pruned {
new_child.size = children[containing_index].size;
} else if let Some(hint) = source_slot_size {
new_child.size = hint;
} else {
let target_share = document_split_share(&children[containing_index].subdivision, &new_child.subdivision);
let total = children[containing_index].size;
children[containing_index].size = total * target_share;
new_child.size = total * (1. - target_share);
}
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)
// Direction mismatch: wrap target in a sub-split at depth+1, sharing the slot in the default ratio
else {
let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] });
let old_share = document_split_share(&old_child_subdivision, &new_child.subdivision);
let old_child = SplitChild {
subdivision: old_child_subdivision,
size: 50.,
size: old_share,
};
let mut new_child = new_child;
new_child.size = 1. - old_share;
if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision {
if insert_before {
@@ -547,7 +661,25 @@ impl PanelLayoutSubdivision {
// 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)
.insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1, source_slot_size)
}
/// Find the size of the `SplitChild` slot whose subdivision is the panel group with the given ID, if it exists.
pub fn find_slot_size_by_group_id(&self, group_id: PanelGroupId) -> Option<f64> {
let PanelLayoutSubdivision::Split { children } = self else { return None };
for child in children {
if let PanelLayoutSubdivision::PanelGroup { id, .. } = &child.subdivision
&& *id == group_id
{
return Some(child.size);
}
}
for child in children {
if let Some(size) = child.subdivision.find_slot_size_by_group_id(group_id) {
return Some(size);
}
}
None
}
/// Check if this subtree contains the document panel.
@@ -558,39 +690,46 @@ impl PanelLayoutSubdivision {
}
}
/// Recalculate the default sizes for this subdivision's children based on proximity to the document panel.
/// Recalculate the default sizes for this subdivision's direct children based on proximity to the document panel.
/// Splits directly surrounding the document panel use 80-20 weighting.
/// All other splits use equal division.
/// Does not recurse into descendants: use [`Self::recalculate_default_sizes_recursive`] for that.
pub fn recalculate_default_sizes(&mut self) {
if let PanelLayoutSubdivision::Split { children } = self {
let child_count = children.len();
if child_count == 0 {
return;
let PanelLayoutSubdivision::Split { children } = self else { return };
let child_count = children.len();
if child_count == 0 {
return;
}
// Check if any child directly contains (or is) the document panel
let document_child_index = children.iter().position(|child| child.subdivision.contains_document());
if let Some(document_index) = document_child_index {
// This split directly surrounds the document panel
let non_document_count = child_count - 1;
let document_share = if non_document_count > 0 { DOCUMENT_PANEL_SHARE } else { 1. };
let other_share = if non_document_count > 0 { (1. - DOCUMENT_PANEL_SHARE) / non_document_count as f64 } else { 0. };
for (i, child) in children.iter_mut().enumerate() {
child.size = if i == document_index { document_share } else { other_share };
}
// Check if any child directly contains (or is) the document panel
let document_child_index = children.iter().position(|child| child.subdivision.contains_document());
if let Some(document_index) = document_child_index {
// This split directly surrounds the document panel, so use 80-20 weighting
let non_document_count = child_count - 1;
let document_share = if non_document_count > 0 { 80. } else { 100. };
let other_share = if non_document_count > 0 { 20. / non_document_count as f64 } else { 0. };
for (i, child) in children.iter_mut().enumerate() {
child.size = if i == document_index { document_share } else { other_share };
}
} else {
// This split doesn't directly contain the document, use equal division
let equal_share = 100. / child_count as f64;
for child in children.iter_mut() {
child.size = equal_share;
}
}
// Recurse into children
} else {
// This split doesn't directly contain the document, use equal division
let equal_share = 1. / child_count as f64;
for child in children.iter_mut() {
child.subdivision.recalculate_default_sizes();
child.size = equal_share;
}
}
}
/// Recalculate the default sizes for this subdivision and all its descendant splits.
pub fn recalculate_default_sizes_recursive(&mut self) {
self.recalculate_default_sizes();
if let PanelLayoutSubdivision::Split { children } = self {
for child in children.iter_mut() {
child.subdivision.recalculate_default_sizes_recursive();
}
}
}
@@ -1430,9 +1430,15 @@ impl Fsm for SelectToolFsmState {
NestedSelectionBehavior::Deepest if remove => drag_deepest_manipulation(responses, selected, tool_data, document, true),
NestedSelectionBehavior::Shallowest if !deepest => drag_shallowest_manipulation(responses, selected, tool_data, document, false, true),
_ => {
responses.add(DocumentMessage::DeselectAllLayers);
tool_data.layers_dragging.clear();
drag_deepest_manipulation(responses, selected, tool_data, document, false)
// Narrow multi-selection to just the clicked layer (no-op if it's already the sole selection)
let currently_selected = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect::<Vec<_>>();
let already_only_selection = currently_selected.as_slice() == [intersection];
if !already_only_selection {
responses.add(DocumentMessage::DeselectAllLayers);
tool_data.layers_dragging.clear();
drag_deepest_manipulation(responses, selected, tool_data, document, false)
}
}
}