mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-22 22:28:11 +08:00
Add support for interactive panel docking (#4015)
* Add interactive panel docking * Preserve active tab when a panel group is docked * Add inter-panel gutter hover color * Code review fixes * More code review
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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).
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)]
|
||||
@@ -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();
|
||||
@@ -409,25 +439,80 @@ 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.
|
||||
pub fn contains_document(&self) -> bool {
|
||||
match self {
|
||||
|
||||
Reference in New Issue
Block a user