Add support for persistent storage of panel layouts, sizes, and active tabs (#4017)

* Add persistence to panel layouts

* Fix and persist the Window > Focus Document mode

* Add a Window > Reset Workspace action

* workspace_layout.json -> workspace_layout.ron

* Fix native app hole punch

* Cleanup review pass
This commit is contained in:
Keavon Chambers
2026-04-08 21:05:58 -07:00
committed by GitHub
parent b099e2faca
commit b100892bfa
20 changed files with 346 additions and 107 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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")

View File

@@ -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<usize>,
},
SetPanelGroupSizes {
/// Path of child indices from the root to the split node whose children's sizes are being set.
split_path: Vec<usize>,
/// New sizes for the children at that split node.
sizes: Vec<f64>,
},
}

View File

@@ -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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
responses.add(PortfolioMessage::SaveWorkspaceLayout);
}
}
PortfolioMessage::RequestWelcomeScreenButtonsLayout => {
@@ -1269,6 +1283,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}
}
PortfolioMessage::ToggleFocusDocument => {
self.focus_document = !self.focus_document;
responses.add(MenuBarMessage::SendLayout);
self.workspace_panel_layout.focus_document = !self.workspace_panel_layout.focus_document;
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);
}
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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<NodeId> {
// 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.

View File

@@ -106,6 +106,12 @@ impl From<String> 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<PanelType>,
#[serde(rename = "activeTabIndex")]
pub active_tab_index: usize,
}
@@ -156,6 +161,12 @@ pub enum PanelLayoutSubdivision {
Split { children: Vec<SplitChild> },
}
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 {