diff --git a/desktop/src/cef/consts.rs b/desktop/src/cef/consts.rs index bcd0e7fc..ced6f6e1 100644 --- a/desktop/src/cef/consts.rs +++ b/desktop/src/cef/consts.rs @@ -1,3 +1,4 @@ +use graphite_desktop_wrapper::DOUBLE_CLICK_MILLISECONDS; use std::time::Duration; pub(crate) const RESOURCE_SCHEME: &str = "resources"; @@ -18,5 +19,5 @@ pub(crate) const SCROLL_SPEED_Y: f32 = 1.0; pub(crate) const PINCH_ZOOM_SPEED: f64 = 300.0; -pub(crate) const MULTICLICK_TIMEOUT: Duration = Duration::from_millis(500); +pub(crate) const MULTICLICK_TIMEOUT: Duration = Duration::from_millis(DOUBLE_CLICK_MILLISECONDS); pub(crate) const MULTICLICK_ALLOWED_TRAVEL: usize = 4; diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 343d924a..9e82f4a0 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -4,7 +4,7 @@ use graphite_editor::messages::prelude::{FrontendMessage, Message}; use message_dispatcher::DesktopWrapperMessageDispatcher; use messages::{DesktopFrontendMessage, DesktopWrapperMessage}; -pub use graphite_editor::consts::FILE_EXTENSION; +pub use graphite_editor::consts::{DOUBLE_CLICK_MILLISECONDS, FILE_EXTENSION}; pub use wgpu_executor::TargetTexture; pub use wgpu_executor::WgpuContext; pub use wgpu_executor::WgpuContextBuilder; diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 1f90f6f0..685a0a79 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1,6 +1,6 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::document::utility_types::network_interface; -use super::utility_types::{PanelGroupId, PanelType, PersistentData, WorkspacePanelLayout}; +use super::utility_types::{PanelType, PersistentData, WorkspacePanelLayout}; use crate::application::{Editor, generate_uuid}; use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION}; use crate::messages::animation::TimingInformation; @@ -469,26 +469,37 @@ impl MessageHandler> for Portfolio return; } - let source_state = self.workspace_panel_layout.panel_group(source_group); + let Some(source_state) = self.workspace_panel_layout.panel_group(source_group) else { return }; let Some(panel_type) = source_state.active_panel_type() else { 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 the moved panel (so backend and frontend start in sync when it remounts) // and for the panel that was previously active in the target panel group (it will be displaced by the incoming tab) Self::destroy_panel_layouts(panel_type, responses); - if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).active_panel_type() { + 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); } // Remove from source panel group - let source = self.workspace_panel_layout.panel_group_mut(source_group); - source.tabs.retain(|&t| t != panel_type); - source.active_tab_index = source.active_tab_index.min(source.tabs.len().saturating_sub(1)); + if let Some(source) = self.workspace_panel_layout.panel_group_mut(source_group) { + source.tabs.retain(|&t| t != panel_type); + source.active_tab_index = source.active_tab_index.min(source.tabs.len().saturating_sub(1)); + } // Insert into target panel group - let target = self.workspace_panel_layout.panel_group_mut(target_group); - let index = insert_index.min(target.tabs.len()); - target.tabs.insert(index, panel_type); - target.active_tab_index = index; + if let Some(target) = self.workspace_panel_layout.panel_group_mut(target_group) { + let index = insert_index.min(target.tabs.len()); + target.tabs.insert(index, panel_type); + target.active_tab_index = index; + } + + // Remove empty panel groups from the tree + self.workspace_panel_layout.prune(); responses.add(MenuBarMessage::SendLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); @@ -497,7 +508,7 @@ impl MessageHandler> for Portfolio self.refresh_panel_content(panel_type, responses); // Refresh the source panel group's newly active tab (if any remain) so it's not left stale - if let Some(new_source_active) = self.workspace_panel_layout.panel_group(source_group).active_panel_type() { + if let Some(new_source_active) = self.workspace_panel_layout.panel_group(source_group).and_then(|g| g.active_panel_type()) { Self::destroy_panel_layouts(new_source_active, responses); self.refresh_panel_content(new_source_active, responses); } @@ -1110,7 +1121,7 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index } => { - let group_state = self.workspace_panel_layout.panel_group_mut(group); + let Some(group_state) = self.workspace_panel_layout.panel_group_mut(group) else { return }; if old_index < group_state.tabs.len() && new_index < group_state.tabs.len() && old_index != new_index { let tab = group_state.tabs.remove(old_index); @@ -1189,7 +1200,7 @@ impl MessageHandler> for Portfolio }); } PortfolioMessage::SetPanelGroupActiveTab { group, tab_index } => { - let group_state = self.workspace_panel_layout.panel_group(group); + let Some(group_state) = self.workspace_panel_layout.panel_group(group) else { return }; if tab_index < group_state.tabs.len() && tab_index != group_state.active_tab_index { // Destroy layouts for the old and new panels so the backend's diffing state is in sync with the frontend's fresh mount if let Some(old_panel_type) = group_state.active_panel_type() { @@ -1199,12 +1210,14 @@ impl MessageHandler> for Portfolio Self::destroy_panel_layouts(new_panel_type, responses); // Update the active tab index for the panel - self.workspace_panel_layout.panel_group_mut(group).active_tab_index = tab_index; + if let Some(group_state) = self.workspace_panel_layout.panel_group_mut(group) { + group_state.active_tab_index = tab_index; + } // Send the layout update first so the frontend mounts the new panel component before it receives content responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); - if let Some(panel_type) = self.workspace_panel_layout.panel_group(group).active_panel_type() { + if let Some(panel_type) = self.workspace_panel_layout.panel_group(group).and_then(|g| g.active_panel_type()) { self.refresh_panel_content(panel_type, responses); } } @@ -1433,6 +1446,8 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::UpdateWorkspacePanelLayout => { + self.workspace_panel_layout.recalculate_default_sizes(); + responses.add(FrontendMessage::UpdateWorkspacePanelLayout { panel_layout: self.workspace_panel_layout.clone(), }); @@ -1652,34 +1667,37 @@ impl PortfolioMessageHandler { selected_nodes.first().copied() } - /// Remove a dockable panel type from whichever panel group currently contains it. + /// Remove a dockable panel type from whichever panel group currently contains it, then prune empty groups. fn remove_panel_from_layout(&mut self, panel_type: PanelType) { - for group_id in [PanelGroupId::PropertiesGroup, PanelGroupId::LayersGroup, PanelGroupId::DataGroup] { - let group = self.workspace_panel_layout.panel_group_mut(group_id); - if let Some(index) = group.tabs.iter().position(|&t| t == panel_type) { - group.tabs.remove(index); - group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1)); - break; - } + // Save the panel's current position so it can be restored there later + self.workspace_panel_layout.save_panel_position(panel_type); + + if let Some(group_id) = self.workspace_panel_layout.find_panel(panel_type) + && let Some(group) = self.workspace_panel_layout.panel_group_mut(group_id) + { + 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). fn toggle_dockable_panel(&mut self, panel_type: PanelType, responses: &mut VecDeque) { if let Some(group_id) = self.workspace_panel_layout.find_panel(panel_type) { // Panel is present, remove it - let was_visible = self.workspace_panel_layout.panel_group(group_id).is_visible(panel_type); + 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); // 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).active_panel_type() { + if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) { Self::destroy_panel_layouts(new_active, responses); self.refresh_panel_content(new_active, responses); } } else { - // Panel is not present, add it to its default panel group - self.add_panel_to_its_default_group(panel_type); + // Panel is not present, restore it to its default position in the layout tree + self.workspace_panel_layout.restore_panel(panel_type); self.refresh_panel_content(panel_type, responses); } @@ -1687,15 +1705,6 @@ impl PortfolioMessageHandler { responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); } - /// Add a dockable panel type to its default panel group. - fn add_panel_to_its_default_group(&mut self, panel_type: PanelType) { - let group = self.workspace_panel_layout.panel_group_mut(panel_type.default_panel_group()); - if !group.tabs.contains(&panel_type) { - group.tabs.push(panel_type); - group.active_tab_index = group.tabs.len() - 1; - } - } - /// Destroy the stored layout for a panel that is no longer the active tab. /// This resets the backend's diffing state so it won't try to send updates to a frontend component that has been unmounted. fn destroy_panel_layouts(panel_type: PanelType, responses: &mut VecDeque) { diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index 180c494d..a00a40b5 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -84,7 +84,7 @@ impl FontCatalogStyle { } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub enum PanelType { Welcome, Document, @@ -93,19 +93,6 @@ pub enum PanelType { Data, } -impl PanelType { - /// Returns the default panel group for this panel type. - pub fn default_panel_group(self) -> PanelGroupId { - match self { - PanelType::Document => PanelGroupId::DocumentGroup, - PanelType::Properties => PanelGroupId::PropertiesGroup, - PanelType::Layers => PanelGroupId::LayersGroup, - PanelType::Data => PanelGroupId::DataGroup, - PanelType::Welcome => panic!("PanelType::{self:?} has no default panel group (not a dockable panel)"), - } - } -} - impl From for PanelType { fn from(value: String) -> Self { match value.as_str() { @@ -119,29 +106,14 @@ impl From for PanelType { } } -/// Identifies a panel group in the workspace that can hold tabbed panels. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub enum PanelGroupId { - DocumentGroup, - PropertiesGroup, - LayersGroup, - DataGroup, -} +/// Unique identifier for a panel group (a leaf subdivision in the layout tree that holds tabs). +#[repr(transparent)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct PanelGroupId(pub u64); -impl From for PanelGroupId { - fn from(value: String) -> Self { - match value.as_str() { - "DocumentGroup" => PanelGroupId::DocumentGroup, - "PropertiesGroup" => PanelGroupId::PropertiesGroup, - "LayersGroup" => PanelGroupId::LayersGroup, - "DataGroup" => PanelGroupId::DataGroup, - _ => panic!("Unknown panel group: {value}"), - } - } -} - -/// State of a single panel group in the workspace. +/// 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)] pub struct PanelGroupState { pub tabs: Vec, @@ -163,68 +135,352 @@ impl PanelGroupState { } } -/// The complete workspace panel layout describing which dockable panels are in which panel groups. +/// A subdivision in the workspace layout tree. The root is always a row (horizontal). +/// Direction alternates at each depth: row, column, row, column, etc. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct WorkspacePanelLayout { - #[serde(rename = "propertiesGroup")] - pub properties_group: PanelGroupState, - #[serde(rename = "layersGroup")] - pub layers_group: PanelGroupState, - #[serde(rename = "dataGroup")] - pub data_group: PanelGroupState, +pub enum PanelLayoutSubdivision { + /// A leaf subdivision: a panel group with tabbed panels. + PanelGroup { id: PanelGroupId, state: PanelGroupState }, + /// A container subdivision that splits its space among children. Direction is implicit from depth (even = row, odd = column). + Split { children: Vec }, } -impl Default for WorkspacePanelLayout { - fn default() -> Self { - Self { - properties_group: PanelGroupState { - tabs: vec![PanelType::Properties], - active_tab_index: 0, - }, - layers_group: PanelGroupState { - tabs: vec![PanelType::Layers], - active_tab_index: 0, - }, - data_group: PanelGroupState { tabs: vec![], active_tab_index: 0 }, - } - } +/// A child within a split container, with a proportional size weight. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SplitChild { + pub subdivision: PanelLayoutSubdivision, + /// Flex-grow weight for proportional sizing. + pub size: f64, +} + +/// The complete workspace panel layout as a tree of nested rows and columns. +/// The root subdivision is always a row (horizontal split). Direction alternates at each depth. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WorkspacePanelLayout { + pub root: PanelLayoutSubdivision, + /// Counter for generating unique panel group IDs. + #[serde(rename = "nextGroupId")] + next_group_id: PanelGroupId, + /// Remembers where a panel was before being removed (panel type, group ID, and tab index), so it can be restored there. + #[serde(default, rename = "savedPositions")] + saved_positions: Vec<(PanelType, PanelGroupId, usize)>, } impl WorkspacePanelLayout { - pub fn panel_group(&self, panel_group_id: PanelGroupId) -> &PanelGroupState { - match panel_group_id { - PanelGroupId::DocumentGroup => panic!("PanelGroupId::{panel_group_id:?} is not a dockable panel group"), - PanelGroupId::PropertiesGroup => &self.properties_group, - PanelGroupId::LayersGroup => &self.layers_group, - PanelGroupId::DataGroup => &self.data_group, - } + /// Generate a new unique panel group ID. + pub fn next_id(&mut self) -> PanelGroupId { + let id = self.next_group_id; + self.next_group_id.0 += 1; + id } - pub fn panel_group_mut(&mut self, panel_group_id: PanelGroupId) -> &mut PanelGroupState { - match panel_group_id { - PanelGroupId::DocumentGroup => panic!("PanelGroupId::{panel_group_id:?} is not a dockable panel group"), - PanelGroupId::PropertiesGroup => &mut self.properties_group, - PanelGroupId::LayersGroup => &mut self.layers_group, - PanelGroupId::DataGroup => &mut self.data_group, - } + /// Find the panel group state for a given ID. + pub fn panel_group(&self, id: PanelGroupId) -> Option<&PanelGroupState> { + self.root.find_group(id) } - /// Find which panel group contains a given panel type. + /// Find the panel group state for a given ID (mutable). + pub fn panel_group_mut(&mut self, id: PanelGroupId) -> Option<&mut PanelGroupState> { + self.root.find_group_mut(id) + } + + /// Find which panel group contains a given panel type, returning its ID. pub fn find_panel(&self, panel_type: PanelType) -> Option { - [PanelGroupId::PropertiesGroup, PanelGroupId::LayersGroup, PanelGroupId::DataGroup] - .into_iter() - .find(|&group_id| self.panel_group(group_id).contains(panel_type)) + self.root.find_panel(panel_type) } /// Check if a panel type is the active (visible) tab in any panel group. pub fn is_panel_visible(&self, panel_type: PanelType) -> bool { - self.find_panel(panel_type).is_some_and(|group_id| self.panel_group(group_id).is_visible(panel_type)) + self.find_panel(panel_type).and_then(|id| self.panel_group(id)).is_some_and(|group| group.is_visible(panel_type)) } /// Check if a panel type is present (as any tab) in any panel group, whether or not it's the active tab. pub fn is_panel_present(&self, panel_type: PanelType) -> bool { self.find_panel(panel_type).is_some() } + + /// Remove empty panel groups and collapse unnecessary single-child splits. + pub fn prune(&mut self) { + self.root.prune(); + } + + /// 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(); + } + + /// Remember which panel group and tab index a panel was in before removal, so it can be restored there later. + pub fn save_panel_position(&mut self, panel_type: PanelType) { + if let Some(group_id) = self.find_panel(panel_type) { + let tab_index = self.panel_group(group_id).and_then(|g| g.tabs.iter().position(|&t| t == panel_type)).unwrap_or(0); + + // Replace any existing saved position for this panel type + self.saved_positions.retain(|(pt, _, _)| *pt != panel_type); + self.saved_positions.push((panel_type, group_id, tab_index)); + } + } + + /// Restore a panel to its previous position if available, otherwise to its default position. + /// If the panel was previously in a group that still exists, it's added back as a tab at its original index. + /// Otherwise, it's placed at its default structural position in the tree. + pub fn restore_panel(&mut self, panel_type: PanelType) { + // Try to restore to the previously saved group and tab position + let saved = self.saved_positions.iter().find(|(pt, _, _)| *pt == panel_type).copied(); + if let Some((_, saved_group_id, saved_tab_index)) = saved + && let Some(group) = self.panel_group_mut(saved_group_id) + { + let insert_index = saved_tab_index.min(group.tabs.len()); + group.tabs.insert(insert_index, panel_type); + group.active_tab_index = insert_index; + self.saved_positions.retain(|(pt, _, _)| *pt != panel_type); + return; + } + self.saved_positions.retain(|(pt, _, _)| *pt != panel_type); + + self.restore_panel_to_default_position(panel_type); + } + + /// Place a panel at its default structural position in the layout tree. + /// - Data: below the document in the left column (root child 0) + /// - Properties: top of the right column (root child 1) + /// - Layers: bottom of the right column (root child 1) + fn restore_panel_to_default_position(&mut self, panel_type: PanelType) { + let new_id = self.next_id(); + let new_group = SplitChild { + subdivision: PanelLayoutSubdivision::PanelGroup { + id: new_id, + state: PanelGroupState { + tabs: vec![panel_type], + active_tab_index: 0, + }, + }, + size: match panel_type { + PanelType::Data => 30., + PanelType::Properties => 45., + PanelType::Layers => 55., + _ => 50., + }, + }; + + // Determine which root child column to insert into and at which position + let (root_child_index, insert_at_end) = match panel_type { + PanelType::Data => (0, true), // Left column, after document + PanelType::Properties => (1, false), // Right column, at top + PanelType::Layers => (1, true), // Right column, at bottom + _ => (1, true), + }; + + // Ensure the root is a split + 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. }); + } + } + + let PanelLayoutSubdivision::Split { children: root_children } = &mut self.root else { return }; + + // Ensure the target root child exists + while root_children.len() <= root_child_index { + root_children.push(SplitChild { + subdivision: PanelLayoutSubdivision::Split { children: vec![] }, + size: 20., + }); + } + + // The target should be a split (column at depth 1) so we can add children to it + let target = &mut root_children[root_child_index].subdivision; + if !matches!(target, PanelLayoutSubdivision::Split { .. }) { + let old_subdivision = std::mem::replace(target, PanelLayoutSubdivision::Split { children: vec![] }); + if let PanelLayoutSubdivision::Split { children } = target { + children.push(SplitChild { + subdivision: old_subdivision, + size: 50., + }); + } + } + + if let PanelLayoutSubdivision::Split { children } = target { + if insert_at_end { + children.push(new_group); + } else { + children.insert(0, new_group); + } + } + } +} + +impl Default for WorkspacePanelLayout { + fn default() -> Self { + // Default layout (sizes are recalculated by `recalculate_default_sizes` before being sent to the frontend): + // Row [ + // Column [Document] + // Column [Properties, Layers] + // ] + Self { + root: PanelLayoutSubdivision::Split { + children: vec![ + SplitChild { + subdivision: PanelLayoutSubdivision::Split { + children: vec![SplitChild { + subdivision: PanelLayoutSubdivision::PanelGroup { + id: PanelGroupId(0), + state: PanelGroupState { + tabs: vec![PanelType::Document], + active_tab_index: 0, + }, + }, + size: 100., + }], + }, + size: 80., + }, + SplitChild { + subdivision: PanelLayoutSubdivision::Split { + children: vec![ + SplitChild { + subdivision: PanelLayoutSubdivision::PanelGroup { + id: PanelGroupId(1), + state: PanelGroupState { + tabs: vec![PanelType::Properties], + active_tab_index: 0, + }, + }, + size: 50., + }, + SplitChild { + subdivision: PanelLayoutSubdivision::PanelGroup { + id: PanelGroupId(2), + state: PanelGroupState { + tabs: vec![PanelType::Layers], + active_tab_index: 0, + }, + }, + size: 50., + }, + ], + }, + size: 20., + }, + ], + }, + next_group_id: PanelGroupId(3), + saved_positions: Vec::new(), + } + } +} + +impl PanelLayoutSubdivision { + /// Find the panel group state for a given ID. + pub fn find_group(&self, target_id: PanelGroupId) -> Option<&PanelGroupState> { + match self { + PanelLayoutSubdivision::PanelGroup { id, state } if *id == target_id => Some(state), + PanelLayoutSubdivision::PanelGroup { .. } => None, + PanelLayoutSubdivision::Split { children } => children.iter().find_map(|child| child.subdivision.find_group(target_id)), + } + } + + /// Find the panel group state for a given ID (mutable). + pub fn find_group_mut(&mut self, target_id: PanelGroupId) -> Option<&mut PanelGroupState> { + match self { + PanelLayoutSubdivision::PanelGroup { id, state } if *id == target_id => Some(state), + PanelLayoutSubdivision::PanelGroup { .. } => None, + PanelLayoutSubdivision::Split { children } => children.iter_mut().find_map(|child| child.subdivision.find_group_mut(target_id)), + } + } + + /// Find the panel group ID that contains a given panel type. + pub fn find_panel(&self, panel_type: PanelType) -> Option { + match self { + PanelLayoutSubdivision::PanelGroup { id, state } if state.contains(panel_type) => Some(*id), + PanelLayoutSubdivision::PanelGroup { .. } => None, + PanelLayoutSubdivision::Split { children } => children.iter().find_map(|child| child.subdivision.find_panel(panel_type)), + } + } + + /// Collect all panel group IDs in the tree. + pub fn all_group_ids(&self) -> Vec { + match self { + PanelLayoutSubdivision::PanelGroup { id, .. } => vec![*id], + PanelLayoutSubdivision::Split { children } => children.iter().flat_map(|child| child.subdivision.all_group_ids()).collect(), + } + } + + /// Remove empty panel groups and collapse single-child splits. + pub fn prune(&mut self) { + if let PanelLayoutSubdivision::Split { children } = self { + // Recursively prune children first + children.iter_mut().for_each(|child| child.subdivision.prune()); + + // Remove empty panel groups + children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty())); + + // Remove empty splits (splits that lost all their children after pruning) + children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.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; + } + } + } + + /// Check if this subtree contains the document panel. + pub fn contains_document(&self) -> bool { + match self { + PanelLayoutSubdivision::PanelGroup { state, .. } => state.contains(PanelType::Document) || state.contains(PanelType::Welcome), + PanelLayoutSubdivision::Split { children } => children.iter().any(|child| child.subdivision.contains_document()), + } + } + + /// Recalculate the default sizes for this subdivision's children based on proximity to the document panel. + /// Splits directly surrounding the document panel use 80-20 weighting. + /// All other splits use equal division. + pub fn recalculate_default_sizes(&mut self) { + if let PanelLayoutSubdivision::Split { children } = self { + 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, 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 + for child in children.iter_mut() { + child.subdivision.recalculate_default_sizes(); + } + } + } + + /// Remove a panel group by ID from the tree. Does not prune. + pub fn remove_group(&mut self, target_id: PanelGroupId) { + if let PanelLayoutSubdivision::Split { children } = self { + children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id)); + + children.iter_mut().for_each(|child| child.subdivision.remove_group(target_id)); + } + } } pub enum FileContent { diff --git a/frontend/src/components/window/MainWindow.svelte b/frontend/src/components/window/MainWindow.svelte index dcb42f7c..7c8fd120 100644 --- a/frontend/src/components/window/MainWindow.svelte +++ b/frontend/src/components/window/MainWindow.svelte @@ -3,24 +3,29 @@ import Dialog from "/src/components/floating-menus/Dialog.svelte"; import Tooltip from "/src/components/floating-menus/Tooltip.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; + import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; + import PanelSubdivision from "/src/components/window/PanelSubdivision.svelte"; import StatusBar from "/src/components/window/StatusBar.svelte"; import TitleBar from "/src/components/window/TitleBar.svelte"; - import Workspace from "/src/components/window/Workspace.svelte"; import type { AppWindowStore } from "/src/stores/app-window"; import type { DialogStore } from "/src/stores/dialog"; + import type { PortfolioStore } from "/src/stores/portfolio"; import type { TooltipStore } from "/src/stores/tooltip"; const dialog = getContext("dialog"); const tooltip = getContext("tooltip"); const appWindow = getContext("appWindow"); + const portfolio = getContext("portfolio"); {#if !($appWindow.platform == "Mac" && $appWindow.fullscreen)} {/if} - + + + {#if $dialog.visible} @@ -46,25 +51,63 @@ height: 100%; overflow: auto; touch-action: none; - } - .release-candidate-expiry { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: var(--color-e-nearwhite); - color: var(--color-2-mildblack); - opacity: 0.9; - pointer-events: none; - padding: 12px 40px; - border-radius: 4px; - text-align-last: justify; - font-size: 18px; - z-index: 1000; + .workspace { + position: relative; + flex: 1 1 100%; - .text-label { - line-height: 1.5; + .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 + .viewport-hole-punch .workspace .workspace-grid-subdivision:has(.panel.document-panel)::after { + content: ""; + position: absolute; + inset: 6px; + border-radius: 6px; + box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack); + z-index: -1; + } + + .release-candidate-expiry { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--color-e-nearwhite); + color: var(--color-2-mildblack); + opacity: 0.9; + pointer-events: none; + padding: 12px 40px; + border-radius: 4px; + text-align-last: justify; + font-size: 18px; + z-index: 1000; + + .text-label { + line-height: 1.5; + } } } diff --git a/frontend/src/components/window/Panel.svelte b/frontend/src/components/window/Panel.svelte index 9d33cbc2..cd69f4fd 100644 --- a/frontend/src/components/window/Panel.svelte +++ b/frontend/src/components/window/Panel.svelte @@ -10,7 +10,7 @@ import IconButton from "/src/components/widgets/buttons/IconButton.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover } from "/src/stores/panel-drag"; - import type { EditorWrapper, PanelType, PanelGroupId } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; const PANEL_COMPONENTS = { Welcome, @@ -31,7 +31,7 @@ export let tabLabels: { name: string; unsaved?: boolean; tooltipLabel?: string; tooltipDescription?: string; tooltipShortcut?: string }[]; export let tabActiveIndex: number; export let panelTypes: PanelType[]; - export let panelId: PanelGroupId; + export let panelId: string; export let clickAction: ((index: number) => void) | undefined = undefined; export let closeAction: ((index: number) => void) | undefined = undefined; export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined; diff --git a/frontend/src/components/window/PanelSubdivision.svelte b/frontend/src/components/window/PanelSubdivision.svelte new file mode 100644 index 00000000..94303565 --- /dev/null +++ b/frontend/src/components/window/PanelSubdivision.svelte @@ -0,0 +1,200 @@ + + +{#if "PanelGroup" in subdivision} + {@const group = subdivision.PanelGroup} + {#if isDocumentGroup(group.state)} + 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]} + tabCloseButtons={true} + tabMinWidths={true} + tabLabels={documentTabLabels} + emptySpaceAction={() => editor.newDocumentDialog()} + clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)} + closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)} + reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)} + tabActiveIndex={$portfolio.activeDocumentIndex} + /> + {:else} + ({ name }))} + tabActiveIndex={Number(group.state.activeTabIndex)} + clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)} + reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)} + crossPanelDropAction={crossPanelDrop} + /> + {/if} +{:else if "Split" in subdivision} + {#each subdivision.Split.children as child, index} + {#if index > 0} + {#if horizontal} + resizePanel(e, index - 1, index)} /> + {:else} + resizePanel(e, index - 1, index)} /> + {/if} + {/if} + {#if horizontal} + + + + {:else} + + + + {/if} + {/each} +{/if} diff --git a/frontend/src/components/window/Workspace.svelte b/frontend/src/components/window/Workspace.svelte deleted file mode 100644 index 01b1e663..00000000 --- a/frontend/src/components/window/Workspace.svelte +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - - 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]} - tabCloseButtons={true} - tabMinWidths={true} - tabLabels={documentTabLabels} - emptySpaceAction={() => editor.newDocumentDialog()} - clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)} - closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)} - reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)} - tabActiveIndex={$portfolio.activeDocumentIndex} - bind:this={documentPanel} - /> - - {#if dataGroup.tabs.length > 0} - resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} /> - - ({ name }))} - tabActiveIndex={dataGroup.activeTabIndex} - clickAction={(tabIndex) => editor.setPanelGroupActiveTab("DataGroup", tabIndex)} - reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("DataGroup", oldIndex, newIndex)} - crossPanelDropAction={crossPanelDrop} - /> - - {/if} - - {#if propertiesGroup.tabs.length > 0 || layersGroup.tabs.length > 0} - resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} /> - - {#if propertiesGroup.tabs.length > 0} - - ({ name }))} - tabActiveIndex={propertiesGroup.activeTabIndex} - clickAction={(tabIndex) => editor.setPanelGroupActiveTab("PropertiesGroup", tabIndex)} - reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("PropertiesGroup", oldIndex, newIndex)} - crossPanelDropAction={crossPanelDrop} - /> - - {/if} - {#if propertiesGroup.tabs.length > 0 && layersGroup.tabs.length > 0} - resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} /> - {/if} - {#if layersGroup.tabs.length > 0} - - ({ name }))} - tabActiveIndex={layersGroup.activeTabIndex} - clickAction={(tabIndex) => editor.setPanelGroupActiveTab("LayersGroup", tabIndex)} - reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("LayersGroup", oldIndex, newIndex)} - crossPanelDropAction={crossPanelDrop} - /> - - {/if} - - {/if} - - - - diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 61411c0f..fd283cff 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -4,21 +4,10 @@ import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { storeDocumentTabOrder } from "/src/utility-functions/persistence"; import { rasterizeSVG } from "/src/utility-functions/rasterization"; -import type { EditorWrapper, OpenDocument, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { EditorWrapper, OpenDocument, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; export type PortfolioStore = ReturnType; -export type PanelGroupState = { - tabs: PanelType[]; - activeTabIndex: number; -}; - -export type WorkspacePanelLayout = { - propertiesGroup: PanelGroupState; - layersGroup: PanelGroupState; - dataGroup: PanelGroupState; -}; - type PortfolioStoreState = { unsaved: boolean; documents: OpenDocument[]; @@ -29,11 +18,7 @@ const initialState: PortfolioStoreState = { unsaved: false, documents: [], activeDocumentIndex: 0, - panelLayout: { - propertiesGroup: { tabs: ["Properties"], activeTabIndex: 0 }, - layersGroup: { tabs: ["Layers"], activeTabIndex: 0 }, - dataGroup: { tabs: [], activeTabIndex: 0 }, - }, + panelLayout: { root: { Split: { children: [] } }, nextGroupId: 0n }, }; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; @@ -115,14 +100,8 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => { - // Coerce activeTabIndex from BigInt (produced by serde_wasm_bindgen for usize) to number - const layout = data.panelLayout; - layout.propertiesGroup.activeTabIndex = Number(layout.propertiesGroup.activeTabIndex); - layout.layersGroup.activeTabIndex = Number(layout.layersGroup.activeTabIndex); - layout.dataGroup.activeTabIndex = Number(layout.dataGroup.activeTabIndex); - update((state) => { - state.panelLayout = layout; + state.panelLayout = data.panelLayout; return state; }); }); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 6cd530cb..38524035 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -18,7 +18,7 @@ use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; -use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily}; +use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily, PanelGroupId}; use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::WidgetId; use graph_craft::document::NodeId; @@ -435,25 +435,31 @@ impl EditorWrapper { } #[wasm_bindgen(js_name = reorderPanelGroupTab)] - pub fn reorder_panel_group_tab(&self, group: String, old_index: usize, new_index: usize) { - let group = group.into(); - let message = PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index }; + pub fn reorder_panel_group_tab(&self, group: u64, old_index: usize, new_index: usize) { + let message = PortfolioMessage::ReorderPanelGroupTab { + group: PanelGroupId(group), + old_index, + new_index, + }; self.dispatch(message); } #[wasm_bindgen(js_name = movePanelTab)] - pub fn move_panel_tab(&self, source_group: String, target_group: String, insert_index: usize) { + pub fn move_panel_tab(&self, source_group: u64, target_group: u64, insert_index: usize) { let message = PortfolioMessage::MovePanelTab { - source_group: source_group.into(), - target_group: target_group.into(), + source_group: PanelGroupId(source_group), + target_group: PanelGroupId(target_group), insert_index, }; self.dispatch(message); } #[wasm_bindgen(js_name = setPanelGroupActiveTab)] - pub fn set_panel_group_active_tab(&self, group: String, tab_index: usize) { - let message = PortfolioMessage::SetPanelGroupActiveTab { group: group.into(), tab_index }; + pub fn set_panel_group_active_tab(&self, group: u64, tab_index: usize) { + let message = PortfolioMessage::SetPanelGroupActiveTab { + group: PanelGroupId(group), + tab_index, + }; self.dispatch(message); }