diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 78dfe4e7c2..e6812b7abe 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -21,6 +21,7 @@ use crate::persist::PersistentData; use crate::preferences; use crate::render::{RenderError, RenderState}; use crate::window::Window; +use crate::workspace_layout; use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, InputMessage, MouseKeys, MouseState, Preferences}; use crate::wrapper::{DesktopWrapper, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; @@ -304,6 +305,15 @@ impl App { let message = DesktopWrapperMessage::LoadPreferences { preferences }; responses.push(message); } + DesktopFrontendMessage::PersistenceWriteWorkspaceLayout { workspace_layout: layout } => { + workspace_layout::write(&layout); + } + DesktopFrontendMessage::PersistenceLoadWorkspaceLayout => { + if let Some(workspace_layout) = workspace_layout::read() { + let message = DesktopWrapperMessage::LoadWorkspaceLayout { workspace_layout }; + responses.push(message); + } + } DesktopFrontendMessage::PersistenceLoadCurrentDocument => { if let Some((id, document)) = self.persistent_data.current_document() { let message = DesktopWrapperMessage::LoadDocument { diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index d51594ea16..9178700fa5 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -9,6 +9,7 @@ pub(crate) const APP_DIRECTORY_NAME: &str = "Graphite"; pub(crate) const APP_LOCK_FILE_NAME: &str = "instance.lock"; pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron"; pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron"; +pub(crate) const APP_WORKSPACE_LAYOUT_FILE_NAME: &str = "workspace_layout.ron"; pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents"; // CEF configuration constants diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 125819e5a7..8448c0e30a 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -20,6 +20,7 @@ mod persist; mod preferences; mod render; mod window; +mod workspace_layout; pub(crate) mod consts; diff --git a/desktop/src/window/win/native_handle.rs b/desktop/src/window/win/native_handle.rs index b0a08fefdf..89f062690c 100644 --- a/desktop/src/window/win/native_handle.rs +++ b/desktop/src/window/win/native_handle.rs @@ -72,7 +72,7 @@ impl NativeWindowHandle { // Subclass the main window. // https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setwindowlongptra - let prev_window_message_handler = unsafe { SetWindowLongPtrW(main, GWLP_WNDPROC, main_window_handle_message as isize) }; + let prev_window_message_handler = unsafe { SetWindowLongPtrW(main, GWLP_WNDPROC, main_window_handle_message as *const () as isize) }; if prev_window_message_handler == 0 { let _ = unsafe { DestroyWindow(helper) }; panic!("SetWindowLongPtrW failed"); diff --git a/desktop/src/workspace_layout.rs b/desktop/src/workspace_layout.rs new file mode 100644 index 0000000000..079fbce42f --- /dev/null +++ b/desktop/src/workspace_layout.rs @@ -0,0 +1,15 @@ +pub(crate) fn write(workspace_layout: &str) { + std::fs::write(file_path(), workspace_layout).unwrap_or_else(|e| { + tracing::error!("Failed to write workspace layout to disk: {e}"); + }); +} + +pub(crate) fn read() -> Option { + std::fs::read_to_string(file_path()).ok() +} + +fn file_path() -> std::path::PathBuf { + let mut path = crate::dirs::app_data_dir(); + path.push(crate::consts::APP_WORKSPACE_LAYOUT_FILE_NAME); + path +} diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index a62fe0b3f9..88df1f620d 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -75,6 +75,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess let message = PreferencesMessage::Load { preferences }; dispatcher.queue_editor_message(message); } + DesktopWrapperMessage::LoadWorkspaceLayout { workspace_layout } => match ron::from_str(&workspace_layout) { + Ok(layout) => { + let message = PortfolioMessage::LoadWorkspaceLayout { layout }; + dispatcher.queue_editor_message(message); + } + Err(e) => { + tracing::error!("Failed to deserialize workspace layout: {e}"); + } + }, #[cfg(target_os = "macos")] DesktopWrapperMessage::MenuEvent { id } => { if let Some(message) = crate::utils::menu::parse_item_path(id) { diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 4d3b980a63..a5fd66c23f 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -110,6 +110,16 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerLoadPreferences => { dispatcher.respond(DesktopFrontendMessage::PersistenceLoadPreferences); } + FrontendMessage::TriggerSaveWorkspaceLayout { workspace_layout } => { + let Ok(workspace_layout) = ron::ser::to_string_pretty(&workspace_layout, ron::ser::PrettyConfig::default()) else { + tracing::error!("Failed to serialize workspace layout"); + return None; + }; + dispatcher.respond(DesktopFrontendMessage::PersistenceWriteWorkspaceLayout { workspace_layout }); + } + FrontendMessage::TriggerLoadWorkspaceLayout => { + dispatcher.respond(DesktopFrontendMessage::PersistenceLoadWorkspaceLayout); + } #[cfg(target_os = "macos")] FrontendMessage::UpdateLayout { layout_target: LayoutTarget::MenuBar, diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 7b06ba260c..b067a4eee3 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -58,6 +58,10 @@ pub enum DesktopFrontendMessage { preferences: Preferences, }, PersistenceLoadPreferences, + PersistenceWriteWorkspaceLayout { + workspace_layout: String, + }, + PersistenceLoadWorkspaceLayout, UpdateMenu { entries: Vec, }, @@ -117,6 +121,9 @@ pub enum DesktopWrapperMessage { LoadPreferences { preferences: Preferences, }, + LoadWorkspaceLayout { + workspace_layout: String, + }, MenuEvent { id: String, }, diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index c6dd3f7aae..db060cebbf 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -234,7 +234,7 @@ impl Dispatcher { Message::MenuBar(message) => { let menu_bar_message_handler = &mut self.message_handlers.menu_bar_message_handler; - menu_bar_message_handler.focus_document = self.message_handlers.portfolio_message_handler.focus_document; + menu_bar_message_handler.focus_document = self.message_handlers.portfolio_message_handler.workspace_panel_layout.focus_document; let layout = &self.message_handlers.portfolio_message_handler.workspace_panel_layout; menu_bar_message_handler.data_panel_open = layout.is_panel_present(PanelType::Data); menu_bar_message_handler.layers_panel_open = layout.is_panel_present(PanelType::Layers); diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index dffd9e9c92..f4b35a0c25 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -127,12 +127,17 @@ pub enum FrontendMessage { TriggerLoadRestAutoSaveDocuments, TriggerOpenLaunchDocuments, TriggerLoadPreferences, + TriggerLoadWorkspaceLayout, TriggerOpen, TriggerImport, TriggerSavePreferences { #[tsify(type = "unknown")] preferences: PreferencesMessageHandler, }, + TriggerSaveWorkspaceLayout { + #[serde(rename = "workspaceLayout")] + workspace_layout: WorkspacePanelLayout, + }, TriggerSaveActiveDocument { #[serde(rename = "documentId")] document_id: DocumentId, diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 1b620ea7d2..689a6a1439 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -647,6 +647,12 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleFocusDocument)) .on_commit(|_| PortfolioMessage::ToggleFocusDocument.into()), ], + vec![ + MenuListEntry::new("Reset Workspace") + .label("Reset Workspace") + .icon("Reset") + .on_commit(|_| PortfolioMessage::ResetWorkspaceLayout.into()), + ], vec![ MenuListEntry::new("Properties") .label("Properties") diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index ecd9e89e32..ba7aea9052 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,5 +1,5 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; -use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType}; +use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType, WorkspacePanelLayout}; 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,9 @@ pub enum PortfolioMessage { LoadDocumentResources { document_id: DocumentId, }, + LoadWorkspaceLayout { + layout: WorkspacePanelLayout, + }, MoveAllPanelTabs { source_group: PanelGroupId, target_group: PanelGroupId, @@ -184,4 +187,16 @@ pub enum PortfolioMessage { UpdateDocumentWidgets, UpdateOpenDocumentsList, UpdateWorkspacePanelLayout, + SaveWorkspaceLayout, + 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, + }, + SetPanelGroupSizes { + /// Path of child indices from the root to the split node whose children's sizes are being set. + split_path: Vec, + /// New sizes for the children at that split node. + sizes: Vec, + }, } diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 7005941940..a2a4a1f3ea 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::{PanelType, PersistentData, WorkspacePanelLayout}; +use super::utility_types::{PanelLayoutSubdivision, 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; @@ -57,7 +57,6 @@ pub struct PortfolioMessageHandler { pub executor: NodeGraphExecutor, pub selection_mode: SelectionMode, pub reset_node_definitions_on_open: bool, - pub focus_document: bool, pub workspace_panel_layout: WorkspacePanelLayout, } @@ -88,9 +87,9 @@ impl MessageHandler> for Portfolio current_tool, preferences, viewport, - data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.focus_document, - layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, - properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.focus_document, + data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.workspace_panel_layout.focus_document, + layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document, + properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.workspace_panel_layout.focus_document, }; document.process_message(message, responses, document_inputs) } @@ -105,6 +104,7 @@ impl MessageHandler> for Portfolio // Tell frontend to load persistent preferences responses.add(FrontendMessage::TriggerLoadPreferences); + responses.add(FrontendMessage::TriggerLoadWorkspaceLayout); // Before loading any documents, initially prepare the welcome screen buttons layout responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); @@ -155,9 +155,9 @@ impl MessageHandler> for Portfolio current_tool, preferences, viewport, - data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.focus_document, - layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, - properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.focus_document, + data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.workspace_panel_layout.focus_document, + layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document, + properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.workspace_panel_layout.focus_document, }; document.process_message(message, responses, document_inputs) } @@ -445,6 +445,17 @@ impl MessageHandler> for Portfolio document.load_layer_resources(responses); } } + PortfolioMessage::LoadWorkspaceLayout { layout } => { + self.workspace_panel_layout = layout; + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + + // Refresh all visible panels since the layout may have changed + 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); + } + } + } PortfolioMessage::NewDocumentWithName { name } => { let mut new_document = DocumentMessageHandler::default(); new_document.name = name; @@ -507,6 +518,7 @@ impl MessageHandler> for Portfolio responses.add(MenuBarMessage::SendLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); // 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()) { @@ -556,6 +568,7 @@ impl MessageHandler> for Portfolio responses.add(MenuBarMessage::SendLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); // Refresh the moved panel's content in its new location self.refresh_panel_content(panel_type, responses); @@ -1190,6 +1203,7 @@ impl MessageHandler> for Portfolio } responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); } } PortfolioMessage::RequestWelcomeScreenButtonsLayout => { @@ -1269,6 +1283,7 @@ impl MessageHandler> for Portfolio // Send the layout update first so the frontend mounts the new panel component before it receives content responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); 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); @@ -1303,6 +1318,7 @@ impl MessageHandler> for Portfolio responses.add(MenuBarMessage::SendLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); // 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()) { @@ -1465,43 +1481,25 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::ToggleFocusDocument => { - self.focus_document = !self.focus_document; - responses.add(MenuBarMessage::SendLayout); - - let properties_present = self.workspace_panel_layout.is_panel_present(PanelType::Properties); - let layers_present = self.workspace_panel_layout.is_panel_present(PanelType::Layers); - let data_present = self.workspace_panel_layout.is_panel_present(PanelType::Data); - - if self.focus_document { - if properties_present { - Self::destroy_panel_layouts(PanelType::Properties, responses); - } - if layers_present { - Self::destroy_panel_layouts(PanelType::Layers, responses); - } - if data_present { - Self::destroy_panel_layouts(PanelType::Data, responses); - } - } else { - // Run the graph to grab the data - if properties_present || layers_present || data_present { - responses.add(NodeGraphMessage::RunDocumentGraph); - } + self.workspace_panel_layout.focus_document = !self.workspace_panel_layout.focus_document; - if properties_present { - responses.add(PropertiesPanelMessage::Refresh); - } - if layers_present && self.active_document_id.is_some() { - responses.add(DeferMessage::AfterGraphRun { - messages: vec![NodeGraphMessage::UpdateLayerPanel.into(), DocumentMessage::DocumentStructureChanged.into()], - }); + // Destroy or refresh non-document panel layouts based on focus mode + for &panel_type in PanelType::non_document_panels() { + if self.workspace_panel_layout.is_panel_present(panel_type) { + if self.workspace_panel_layout.focus_document { + Self::destroy_panel_layouts(panel_type, responses); + } else { + self.refresh_panel_content(panel_type, responses); + } } } + responses.add(MenuBarMessage::SendLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); } PortfolioMessage::TogglePropertiesPanelOpen => { - if self.focus_document { + if self.workspace_panel_layout.focus_document { return; } @@ -1509,7 +1507,7 @@ impl MessageHandler> for Portfolio self.toggle_dockable_panel(panel_type, responses); } PortfolioMessage::ToggleLayersPanelOpen => { - if self.focus_document { + if self.workspace_panel_layout.focus_document { return; } @@ -1517,7 +1515,7 @@ impl MessageHandler> for Portfolio self.toggle_dockable_panel(panel_type, responses); } PortfolioMessage::ToggleDataPanelOpen => { - if self.focus_document { + if self.workspace_panel_layout.focus_document { return; } @@ -1538,11 +1536,72 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::UpdateWorkspacePanelLayout => { + let panel_layout = match self.workspace_panel_layout.focus_document { + true => self.workspace_panel_layout.document_only_layout(), + false => self.workspace_panel_layout.clone(), + }; + responses.add(FrontendMessage::UpdateWorkspacePanelLayout { panel_layout }); + } + PortfolioMessage::SaveWorkspaceLayout => { + responses.add(FrontendMessage::TriggerSaveWorkspaceLayout { + workspace_layout: self.workspace_panel_layout.clone(), + }); + } + PortfolioMessage::ResetWorkspaceLayout => { + // Destroy layouts for all currently visible non-document panels + for &panel_type in PanelType::non_document_panels() { + if self.workspace_panel_layout.is_panel_present(panel_type) { + Self::destroy_panel_layouts(panel_type, responses); + } + } + + // Replace layout with the default and recalculate sizes + self.workspace_panel_layout = WorkspacePanelLayout::default(); self.workspace_panel_layout.recalculate_default_sizes(); - responses.add(FrontendMessage::UpdateWorkspacePanelLayout { - panel_layout: self.workspace_panel_layout.clone(), - }); + // Refresh all visible panels since the layout has been completely replaced + 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); + } + } + + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); + 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); + responses.add(PortfolioMessage::SaveWorkspaceLayout); + } + 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; + 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; + } + + // Apply the new sizes to the split's children + if let PanelLayoutSubdivision::Split { children } = node { + for (child, &size) in children.iter_mut().zip(sizes.iter()) { + child.size = size; + } + } + + responses.add(PortfolioMessage::SaveWorkspaceLayout); } PortfolioMessage::UpdateOpenDocumentsList => { // Send the list of document tab names @@ -1602,7 +1661,7 @@ impl MessageHandler> for Portfolio } // Extend with actions that are disabled when focusing the document - if !self.focus_document { + if !self.workspace_panel_layout.focus_document { common.extend(actions!(PortfolioMessageDiscriminant; TogglePropertiesPanelOpen, ToggleLayersPanelOpen, @@ -1696,8 +1755,14 @@ impl PortfolioMessageHandler { } else { self.document_ids.push_back(document_id); } - new_document.update_layers_panel_control_bar_widgets(self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, responses); - new_document.update_layers_panel_bottom_bar_widgets(self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, responses); + new_document.update_layers_panel_control_bar_widgets( + self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document, + responses, + ); + new_document.update_layers_panel_bottom_bar_widgets( + self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document, + responses, + ); self.documents.insert(document_id, new_document); @@ -1744,7 +1809,7 @@ impl PortfolioMessageHandler { /// Get the ID of the selected node that should be used as the current source for the Data panel. pub fn node_to_inspect(&self) -> Option { // Skip if the Data panel is not open - if !self.workspace_panel_layout.is_panel_visible(PanelType::Data) || self.focus_document { + if !self.workspace_panel_layout.is_panel_visible(PanelType::Data) || self.workspace_panel_layout.focus_document { return None; } @@ -1794,6 +1859,7 @@ impl PortfolioMessageHandler { responses.add(MenuBarMessage::SendLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + responses.add(PortfolioMessage::SaveWorkspaceLayout); } /// Destroy the stored layout for a panel that is no longer the active tab. diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index 63d96395ed..5aff092da6 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -106,6 +106,12 @@ impl From for PanelType { } } +impl PanelType { + pub fn non_document_panels() -> &'static [PanelType] { + &[PanelType::Layers, PanelType::Properties, PanelType::Data] + } +} + /// 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))] @@ -127,7 +133,6 @@ pub enum DockingSplitDirection { #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PanelGroupState { pub tabs: Vec, - #[serde(rename = "activeTabIndex")] pub active_tab_index: usize, } @@ -156,6 +161,12 @@ pub enum PanelLayoutSubdivision { Split { children: Vec }, } +impl Default for PanelLayoutSubdivision { + fn default() -> Self { + PanelLayoutSubdivision::Split { children: Vec::new() } + } +} + /// 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)] @@ -170,13 +181,17 @@ pub struct SplitChild { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct WorkspacePanelLayout { + #[serde(default)] pub root: PanelLayoutSubdivision, /// Counter for generating unique panel group IDs. - #[serde(rename = "nextGroupId")] + #[serde(default)] 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")] + #[serde(default)] saved_positions: Vec<(PanelType, PanelGroupId, usize)>, + /// Whether Focus Document mode is active, hiding all non-document panels. + #[serde(default)] + pub focus_document: bool, } impl WorkspacePanelLayout { @@ -217,6 +232,14 @@ impl WorkspacePanelLayout { self.root.prune(); } + /// Produce a filtered copy of this layout containing only the document panel, for use in Focus Document mode. + pub fn document_only_layout(&self) -> WorkspacePanelLayout { + let mut layout = self.clone(); + layout.root.retain_only_document_panels(); + layout.root.prune(); + layout + } + /// 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. @@ -399,6 +422,7 @@ impl Default for WorkspacePanelLayout { }, next_group_id: PanelGroupId(3), saved_positions: Vec::new(), + focus_document: false, } } } @@ -455,6 +479,19 @@ impl PanelLayoutSubdivision { children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty())); } + /// Remove all non-document/non-welcome tabs from panel groups, leaving only document-related panels. + pub fn retain_only_document_panels(&mut self) { + match self { + PanelLayoutSubdivision::PanelGroup { state, .. } => { + state.tabs.retain(|t| matches!(t, PanelType::Document | PanelType::Welcome)); + state.active_tab_index = state.active_tab_index.min(state.tabs.len().saturating_sub(1)); + } + PanelLayoutSubdivision::Split { children } => { + children.iter_mut().for_each(|child| child.subdivision.retain_only_document_panels()); + } + } + } + /// Check if this subtree contains a panel group with the given ID. pub fn contains_group(&self, target_id: PanelGroupId) -> bool { match self { diff --git a/frontend/src/components/window/MainWindow.svelte b/frontend/src/components/window/MainWindow.svelte index 5e70647c05..73e0880922 100644 --- a/frontend/src/components/window/MainWindow.svelte +++ b/frontend/src/components/window/MainWindow.svelte @@ -51,39 +51,25 @@ height: 100%; overflow: auto; touch-action: none; + } - .workspace { - position: relative; - } - - // 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; + .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; - } + .text-label { + line-height: 1.5; } } diff --git a/frontend/src/components/window/PanelSubdivision.svelte b/frontend/src/components/window/PanelSubdivision.svelte index 5b71fec7c5..f8d2c0299c 100644 --- a/frontend/src/components/window/PanelSubdivision.svelte +++ b/frontend/src/components/window/PanelSubdivision.svelte @@ -12,8 +12,9 @@ const editor = getContext("editor"); const portfolio = getContext("portfolio"); - export let subdivision: PanelLayoutSubdivision; + export let subdivision: PanelLayoutSubdivision | undefined; export let depth: number; + export let splitPath: number[] = []; // Local size overrides for gutter resizing (keyed by child index) let sizeOverrides: Record = {}; @@ -29,7 +30,7 @@ // Reset overrides when the subdivision changes (e.g., backend sends a new layout) $: if (subdivision) sizeOverrides = {}; // Reactive array of resolved sizes (merging backend defaults with local overrides) - $: resolvedSizes = "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : []; + $: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : []; $: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => { const name = doc.details.name; const unsaved = !doc.details.isSaved; @@ -44,7 +45,7 @@ }); function resizePanel(e: PointerEvent, prevIndex: number, nextIndex: number) { - if (!("Split" in subdivision)) return; + if (!(subdivision && "Split" in subdivision)) return; const gutter = e.target; if (!(gutter instanceof HTMLDivElement)) return; @@ -55,13 +56,13 @@ if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return; // Double-click resets both adjacent panels to their default sizes - const children = subdivision.Split.children; const now = Date.now(); const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter; lastGutterClickTime = now; lastGutterClickTarget = gutter; if (isDoubleClick) { - sizeOverrides = { ...sizeOverrides, [prevIndex]: children[prevIndex].size, [nextIndex]: children[nextIndex].size }; + sizeOverrides = {}; + editor.resetPanelGroupSizes(splitPath); return; } @@ -113,6 +114,12 @@ if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId); removeListeners(); activeResizeCleanup = undefined; + + // Persist the resized sizes to the backend + if ("Split" in subdivision) { + const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size); + editor.setPanelGroupSizes(splitPath, allSizes); + } }; const onMouseDown = (e: MouseEvent) => { @@ -159,7 +166,7 @@ } -{#if "PanelGroup" in subdivision} +{#if subdivision && "PanelGroup" in subdivision} {@const group = subdivision.PanelGroup} {#if isDocumentGroup(group.state)} ({ name }))} - tabActiveIndex={Number(group.state.activeTabIndex)} + tabActiveIndex={Number(group.state.active_tab_index)} clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)} reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)} crossPanelDropAction={crossPanelDrop} @@ -190,7 +197,7 @@ splitDropAction={splitDrop} /> {/if} -{:else if "Split" in subdivision} +{:else if subdivision && "Split" in subdivision} {#each subdivision.Split.children as child, index} {#if index > 0} {#if horizontal} @@ -201,28 +208,17 @@ {/if} {#if horizontal} - + {:else} - + {/if} {/each} {/if} diff --git a/frontend/src/managers/persistence.ts b/frontend/src/managers/persistence.ts index a0e688b480..1ea3fdbc77 100644 --- a/frontend/src/managers/persistence.ts +++ b/frontend/src/managers/persistence.ts @@ -1,6 +1,16 @@ import type { PortfolioStore } from "/src/stores/portfolio"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; -import { saveEditorPreferences, loadEditorPreferences, storeDocument, removeDocument, loadFirstDocument, loadRestDocuments, saveActiveDocument } from "/src/utility-functions/persistence"; +import { + saveEditorPreferences, + loadEditorPreferences, + saveWorkspaceLayout, + loadWorkspaceLayout, + storeDocument, + removeDocument, + loadFirstDocument, + loadRestDocuments, + saveActiveDocument, +} from "/src/utility-functions/persistence"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; @@ -22,6 +32,14 @@ export function createPersistenceManager(subscriptions: SubscriptionsRouter, edi await loadEditorPreferences(editor); }); + subscriptions.subscribeFrontendMessage("TriggerSaveWorkspaceLayout", async (data) => { + await saveWorkspaceLayout(data.workspaceLayout); + }); + + subscriptions.subscribeFrontendMessage("TriggerLoadWorkspaceLayout", async () => { + await loadWorkspaceLayout(editor); + }); + subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => { await storeDocument(data, portfolio); }); @@ -53,6 +71,8 @@ export function destroyPersistenceManager() { subscriptions.unsubscribeFrontendMessage("TriggerSavePreferences"); subscriptions.unsubscribeFrontendMessage("TriggerLoadPreferences"); + subscriptions.unsubscribeFrontendMessage("TriggerSaveWorkspaceLayout"); + subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout"); subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument"); subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument"); diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index fd283cff48..60af4e1afe 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -18,7 +18,7 @@ const initialState: PortfolioStoreState = { unsaved: false, documents: [], activeDocumentIndex: 0, - panelLayout: { root: { Split: { children: [] } }, nextGroupId: 0n }, + panelLayout: {}, }; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; diff --git a/frontend/src/utility-functions/persistence.ts b/frontend/src/utility-functions/persistence.ts index 3ef31dadc3..01ae129999 100644 --- a/frontend/src/utility-functions/persistence.ts +++ b/frontend/src/utility-functions/persistence.ts @@ -161,6 +161,15 @@ export async function loadEditorPreferences(editor: EditorWrapper) { editor.loadPreferences(preferences ? JSON.stringify(preferences) : undefined); } +export async function saveWorkspaceLayout(layout: unknown) { + await databaseSet("workspace_layout", layout); +} + +export async function loadWorkspaceLayout(editor: EditorWrapper) { + const layout = await databaseGet>("workspace_layout"); + if (layout) editor.loadWorkspaceLayout(layout); +} + export async function wipeDocuments() { await databaseDelete("documents_tab_order"); await databaseDelete("current_document_id"); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index e4bfd0c12f..a1d60e1605 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -379,6 +379,16 @@ impl EditorWrapper { } } + #[wasm_bindgen(js_name = loadWorkspaceLayout)] + pub fn load_workspace_layout(&self, layout: JsValue) { + let Ok(layout) = serde_wasm_bindgen::from_value(layout) else { + log::error!("Failed to deserialize workspace layout"); + return; + }; + let message = PortfolioMessage::LoadWorkspaceLayout { layout }; + self.dispatch(message); + } + #[wasm_bindgen(js_name = selectDocument)] pub fn select_document(&self, document_id: u64) { let document_id = DocumentId(document_id); @@ -486,6 +496,21 @@ impl EditorWrapper { self.dispatch(message); } + #[wasm_bindgen(js_name = resetPanelGroupSizes)] + pub fn reset_panel_group_sizes(&self, split_path: JsValue) { + let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); + let message = PortfolioMessage::ResetPanelGroupSizes { split_path }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = setPanelGroupSizes)] + pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) { + let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); + let sizes: Vec = serde_wasm_bindgen::from_value(sizes).unwrap(); + let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes }; + self.dispatch(message); + } + #[wasm_bindgen(js_name = closeDocumentWithConfirmation)] pub fn close_document_with_confirmation(&self, document_id: u64) { let document_id = DocumentId(document_id);