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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 346 additions and 107 deletions

View File

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

View File

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

View File

@ -20,6 +20,7 @@ mod persist;
mod preferences;
mod render;
mod window;
mod workspace_layout;
pub(crate) mod consts;

View File

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

View File

@ -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<String> {
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
}

View File

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

View File

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

View File

@ -58,6 +58,10 @@ pub enum DesktopFrontendMessage {
preferences: Preferences,
},
PersistenceLoadPreferences,
PersistenceWriteWorkspaceLayout {
workspace_layout: String,
},
PersistenceLoadWorkspaceLayout,
UpdateMenu {
entries: Vec<MenuItem>,
},
@ -117,6 +121,9 @@ pub enum DesktopWrapperMessage {
LoadPreferences {
preferences: Preferences,
},
LoadWorkspaceLayout {
workspace_layout: String,
},
MenuEvent {
id: String,
},

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 {

View File

@ -51,39 +51,25 @@
height: 100%;
overflow: auto;
touch-action: none;
}
.workspace {
position: relative;
}
.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;
// 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;
}
.text-label {
line-height: 1.5;
}
}
</style>

View File

@ -12,8 +12,9 @@
const editor = getContext<EditorWrapper>("editor");
const portfolio = getContext<PortfolioStore>("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<number, number> = {};
@ -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 @@
}
</script>
{#if "PanelGroup" in subdivision}
{#if subdivision && "PanelGroup" in subdivision}
{@const group = subdivision.PanelGroup}
{#if isDocumentGroup(group.state)}
<Panel
@ -182,7 +189,7 @@
panelId={String(group.id)}
panelTypes={group.state.tabs}
tabLabels={group.state.tabs.map((name) => ({ 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}
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
</LayoutCol>
{:else}
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
<svelte:self subdivision={child.subdivision} depth={depth + 1} splitPath={[...splitPath, index]} />
</LayoutRow>
{/if}
{/each}
{/if}
<style lang="scss">
.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;
border-radius: 2px;
@ -241,4 +237,25 @@
transition: background 0.2s 0.1s;
}
}
.workspace-grid-subdivision {
position: relative;
flex: 1 1 0;
min-height: 28px;
&.folded {
flex-grow: 0;
height: 0;
}
}
// Needed for the viewport hole punch on desktop
.viewport-hole-punch .workspace-grid-subdivision:has(> .panel.document-panel)::after {
content: "";
position: absolute;
z-index: -1;
inset: 6px;
border-radius: 6px;
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
}
</style>

View File

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

View File

@ -18,7 +18,7 @@ const initialState: PortfolioStoreState = {
unsaved: false,
documents: [],
activeDocumentIndex: 0,
panelLayout: { root: { Split: { children: [] } }, nextGroupId: 0n },
panelLayout: {},
};
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;

View File

@ -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<Record<string, unknown>>("workspace_layout");
if (layout) editor.loadWorkspaceLayout(layout);
}
export async function wipeDocuments() {
await databaseDelete("documents_tab_order");
await databaseDelete("current_document_id");

View File

@ -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<usize> = 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<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
let sizes: Vec<f64> = 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);