diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f80b27845..dd0c0cfb09 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,7 +33,7 @@ }, // Rust Analyzer config "rust-analyzer.cargo.allTargets": false, - "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.command": "", // ESLint config "eslint.format.enable": true, "eslint.workingDirectories": ["./frontend", "./website/other/bezier-rs-demos", "./website"], diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 32af59cb1c..904d80bc39 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -23,7 +23,7 @@ pub struct DispatcherMessageHandlers { pub portfolio_message_handler: PortfolioMessageHandler, preferences_message_handler: PreferencesMessageHandler, tool_message_handler: ToolMessageHandler, - workspace_message_handler: WorkspaceMessageHandler, + pub workspace_message_handler: WorkspaceMessageHandler, } /// For optimization, these are messages guaranteed to be redundant when repeated. @@ -99,6 +99,9 @@ impl Dispatcher { // Display the menu bar at the top of the window queue.add(MenuBarMessage::SendLayout); + // Init the dockspace + queue.add(WorkspaceMessage::SendLayout); + // Load the default font let font = Font::new(graphene_core::consts::DEFAULT_FONT_FAMILY.into(), graphene_core::consts::DEFAULT_FONT_STYLE.into()); queue.add(FrontendMessage::TriggerFontLoad { font, is_default: true }); @@ -181,7 +184,9 @@ impl Dispatcher { } } Message::Workspace(message) => { - self.message_handlers.workspace_message_handler.process_message(message, &mut queue, ()); + self.message_handlers + .workspace_message_handler + .process_message(message, &mut queue, &self.message_handlers.portfolio_message_handler); } } diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 0ed17ea8a6..e5406812ea 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -6,6 +6,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{ use crate::messages::portfolio::document::utility_types::nodes::{JsRawBuffer, LayerPanelEntry, RawBuffer}; use crate::messages::prelude::*; use crate::messages::tool::utility_types::HintData; +use crate::messages::workspace::FrontendDivisionOrPanel; use graph_craft::document::NodeId; use graphene_core::raster::color::Color; @@ -164,6 +165,9 @@ pub enum FrontendMessage { layout_target: LayoutTarget, diff: Vec, }, + UpdateDockspace { + root: FrontendDivisionOrPanel, + }, UpdateDocumentArtwork { svg: String, }, diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs index 14963b5897..7832b23d5e 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs @@ -24,7 +24,7 @@ impl MessageHandler for match message { InputPreprocessorMessage::BoundsOfViewports { bounds_of_viewports } => { - assert_eq!(bounds_of_viewports.len(), 1, "Only one viewport is currently supported"); + // assert_eq!(bounds_of_viewports.len(), 1, "Only one viewport is currently supported"); for bounds in bounds_of_viewports { // TODO: Extend this to multiple viewports instead of setting it to the value of this last loop iteration diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index 34610ea5fc..14f77b0365 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -9,6 +9,16 @@ use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, specta::Type)] pub struct DocumentId(pub u64); +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct DocumentViewId(pub u64); + +impl From for DocumentViewId { + fn from(value: u64) -> Self { + Self(value) + } +} + #[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Hash)] pub enum FlipAxis { X, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ad8327c46b..a79895003e 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1,4 +1,5 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; +use super::document::utility_types::misc::DocumentViewId; use super::document::utility_types::network_interface::{self, InputConnector, OutputConnector}; use super::utility_types::{PanelType, PersistentData}; use crate::application::generate_uuid; @@ -10,6 +11,7 @@ use crate::messages::portfolio::document::utility_types::clipboards::{Clipboard, use crate::messages::portfolio::document::DocumentMessageData; use crate::messages::prelude::*; use crate::messages::tool::utility_types::{HintData, HintGroup}; +use crate::messages::workspace::TabType; use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor}; use graph_craft::document::value::TaggedValue; @@ -26,10 +28,16 @@ pub struct PortfolioMessageData<'a> { pub preferences: &'a PreferencesMessageHandler, } +#[derive(Debug)] +pub struct DocumentView { + pub document_id: DocumentId, +} + #[derive(Debug, Default)] pub struct PortfolioMessageHandler { menu_bar_message_handler: MenuBarMessageHandler, pub documents: HashMap, + pub document_views: HashMap, document_ids: Vec, active_panel: PanelType, pub(crate) active_document_id: Option, @@ -816,6 +824,16 @@ impl PortfolioMessageHandler { self.documents.insert(document_id, new_document); + // TODO: remove this and allow users to add views in UI. + for _ in 0..3 { + let document_view_id = DocumentViewId(41); + self.document_views.insert(document_view_id, DocumentView { document_id }); + responses.add(WorkspaceMessage::AddTab { + tab: TabType::document(document_view_id, document_id), + destination: None, + }); + } + if self.active_document().is_some() { responses.add(BroadcastEvent::ToolAbort); responses.add(ToolMessage::DeactivateTools); @@ -863,4 +881,15 @@ impl PortfolioMessageHandler { } result } + + pub fn frontend_document(&self, view_id: DocumentViewId) -> Option { + let document_id = self.document_views.get(&view_id)?.document_id; + let document = self.documents.get(&document_id)?; + Some(FrontendDocumentDetails { + is_auto_saved: document.is_auto_saved(), + is_saved: document.is_saved(), + id: document_id, + name: document.name.clone(), + }) + } } diff --git a/editor/src/messages/workspace/mod.rs b/editor/src/messages/workspace/mod.rs index 891ac11f99..ccf537e57d 100644 --- a/editor/src/messages/workspace/mod.rs +++ b/editor/src/messages/workspace/mod.rs @@ -1,7 +1,11 @@ mod workspace_message; mod workspace_message_handler; +mod workspace_types; #[doc(inline)] pub use workspace_message::{WorkspaceMessage, WorkspaceMessageDiscriminant}; #[doc(inline)] pub use workspace_message_handler::WorkspaceMessageHandler; + +#[doc(inline)] +pub use workspace_types::*; diff --git a/editor/src/messages/workspace/workspace_message.rs b/editor/src/messages/workspace/workspace_message.rs index 66eb0858ad..d9c74377ce 100644 --- a/editor/src/messages/workspace/workspace_message.rs +++ b/editor/src/messages/workspace/workspace_message.rs @@ -1,8 +1,14 @@ -use crate::messages::prelude::*; +pub use super::workspace_types::*; +pub use crate::messages::prelude::*; #[impl_message(Message, Workspace)] -#[derive(PartialEq, Eq, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum WorkspaceMessage { - // Messages - NodeGraphToggleVisibility, + AddTab { tab: TabType, destination: Option }, + DeleteTab { tab: TabPath }, + MoveTab { source: TabPath, destination: TabDestination }, + SelectTab { tab: TabPath }, + ResizeDivision { division: PanelPath, start_size: f64, end_size: f64 }, + + SendLayout, } diff --git a/editor/src/messages/workspace/workspace_message_handler.rs b/editor/src/messages/workspace/workspace_message_handler.rs index 397e7cf00b..d8c082e8a7 100644 --- a/editor/src/messages/workspace/workspace_message_handler.rs +++ b/editor/src/messages/workspace/workspace_message_handler.rs @@ -1,23 +1,431 @@ use crate::messages::prelude::*; -#[derive(Debug, Clone, Default)] +pub use super::workspace_types::*; + +#[derive(Debug, Clone)] pub struct WorkspaceMessageHandler { - node_graph_visible: bool, + root: DivisionOrPanel, } -impl MessageHandler for WorkspaceMessageHandler { - fn process_message(&mut self, message: WorkspaceMessage, _responses: &mut VecDeque, _data: ()) { - match message { - // Messages - WorkspaceMessage::NodeGraphToggleVisibility => { - self.node_graph_visible = !self.node_graph_visible; +impl Default for WorkspaceMessageHandler { + fn default() -> Self { + let documents = Panel::new([]); + let properties = Panel::new([TabType::Properties]); + let layers = Panel::new([TabType::Layers, TabType::Properties, TabType::Layers]); + let right = Division::new(Direction::Vertical, 45., 55., properties, layers); + Self { + root: Division::new(Direction::Horizontal, 80., 20., documents, right).into(), + } + } +} + +impl WorkspaceMessageHandler { + pub fn add_tab(&mut self, tab: TabType, destination: Option) { + let Some(destination) = destination else { + self.root.first_panel_mut().add_tab(tab, None); + return; + }; + if let Some(edge) = destination.edge { + let Some(destination_wrapper) = destination.panel.get_wrapped_mut(&mut self.root) else { + warn!("Invalid destination panel for add"); + return; + }; + let opposite = std::mem::replace(destination_wrapper, Panel::new([]).into()); + let new_panel = Panel::new([tab]).into(); + let [start, end] = if edge.start { [new_panel, opposite] } else { [opposite, new_panel] }; + *destination_wrapper = Division::new(edge.direction, 50., 50., start, end).into(); + } else { + let Some(destination_panel) = destination.panel.get_panel_mut(&mut self.root) else { + warn!("Invalid destination panel for add"); + return; + }; + destination_panel.add_tab(tab, destination.insert_index); + } + } + pub fn delete_tab(&mut self, tab: TabPath) { + let Some(panel) = tab.panel.get_panel_mut(&mut self.root) else { + warn!("Invalid panel for delete"); + return; + }; + panel.remove_tab(tab.tab_index); + if panel.is_empty() { + if let Some((parent, start)) = tab.panel.get_parent_wrapped_mut(&mut self.root) { + parent.replace_with_child(!start); + } + } + } + pub fn move_tab(&mut self, source: TabPath, destination: TabDestination) { + let Some(panel) = source.panel.get_panel_mut(&mut self.root) else { + warn!("Invalid source panel for move"); + return; + }; + + // Don't bother + if destination.panel == source.panel && panel.len() == 1 { + return; + } + + let tab = panel.remove_tab(source.tab_index); + let remove_division = panel.is_empty(); + self.add_tab(tab, Some(destination)); + + if remove_division { + if let Some((parent, start)) = source.panel.get_parent_wrapped_mut(&mut self.root) { + parent.replace_with_child(!start); } } } + pub fn select_tab(&mut self, tab: TabPath) { + let Some(panel) = tab.panel.get_panel_mut(&mut self.root) else { + warn!("Invalid panel for select"); + return; + }; + panel.select_tab(tab.tab_index); + } + pub fn resize_division(&mut self, division: PanelPath, start_size: f64, end_size: f64) { + let Some(division) = division.get_wrapped_mut(&mut self.root).and_then(|wrapped| wrapped.as_division_mut()) else { + warn!("Invalid division for resize"); + return; + }; + division.resize(start_size, end_size); + } + pub fn is_single_tab(&self, panel: PanelPath) -> bool { + let Some(panel) = panel.get_panel(&self.root) else { + warn!("Invalid panel for check single tab"); + return true; + }; + panel.len() == 1 + } + + pub fn send_layout(&self, portfolio: &PortfolioMessageHandler, responses: &mut VecDeque) { + responses.add(FrontendMessage::UpdateDockspace { + root: FrontendDivisionOrPanel::new(&self.root, portfolio, PanelPath::builder()), + }); + } +} + +impl MessageHandler for WorkspaceMessageHandler { + fn process_message(&mut self, message: WorkspaceMessage, responses: &mut VecDeque, portfolio: &PortfolioMessageHandler) { + match message { + WorkspaceMessage::AddTab { tab, destination } => self.add_tab(tab, destination), + WorkspaceMessage::DeleteTab { tab } => self.delete_tab(tab), + WorkspaceMessage::MoveTab { source, destination } => self.move_tab(source, destination), + WorkspaceMessage::SelectTab { tab } => self.select_tab(tab), + WorkspaceMessage::ResizeDivision { division, start_size, end_size } => self.resize_division(division, start_size, end_size), + + WorkspaceMessage::SendLayout => {} // Send layout is called anyway below. + } + self.send_layout(portfolio, responses); + } fn actions(&self) -> ActionList { - actions!(WorkspaceMessageDiscriminant; - NodeGraphToggleVisibility, - ) + actions!(WorkspaceMessageDiscriminant;) + } +} + +#[cfg(test)] +const fn test_tab(identifier: u64) -> TabType { + use crate::messages::portfolio::document::utility_types::misc::DocumentViewId; + + TabType::document(DocumentViewId(identifier), DocumentId(identifier)) +} + +#[cfg(test)] +fn test_layout() -> WorkspaceMessageHandler { + let documents = Panel::new([]); + let end_start = Panel::new([test_tab(1_000)]); + let end_end = Panel::new([test_tab(11_000), test_tab(11_001)]); + let right = Division::new(Direction::Vertical, 45., 55., end_start, end_end); + WorkspaceMessageHandler { + root: Division::new(Direction::Horizontal, 80., 20., documents, right).into(), + } +} + +#[cfg(test)] +const TEST_TAB: TabType = test_tab(123); + +#[test] +fn dockspace_tab_paths() { + let mut workspace = test_layout(); + assert!(PanelPath::builder().get_panel(&workspace.root).is_none()); + assert!(PanelPath::builder().start().start().get_panel(&workspace.root).is_none()); + assert!(PanelPath::builder().end().get_panel(&workspace.root).is_none()); + assert!(PanelPath::builder().end().end().start().get_panel(&workspace.root).is_none()); + assert_eq!(PanelPath::builder().start().get_panel(&workspace.root).unwrap().is_empty(), true); + assert_eq!(PanelPath::builder().end().end().get_panel(&workspace.root).unwrap().len(), 2); + let right_panel = PanelPath::builder().end().end().get_parent_wrapped_mut(&mut workspace.root).unwrap(); + assert_eq!(right_panel.0.as_division().unwrap().direction(), Direction::Vertical); + assert_eq!(right_panel.1, false); + let first_panel = PanelPath::builder().start().get_parent_wrapped_mut(&mut workspace.root).unwrap(); + assert_eq!(first_panel.0.as_division().unwrap().direction(), Direction::Horizontal); + assert_eq!(first_panel.1, true); + let empty_panel = PanelPath::builder().start().get_wrapped_mut(&mut workspace.root).unwrap(); + assert!(empty_panel.as_panel().unwrap().is_empty()); + assert!(PanelPath::builder().start().start().get_wrapped_mut(&mut workspace.root).is_none()); +} + +#[test] +fn dockspace_add_tab() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().start(), + insert_index: None, + edge: None, + }; + workspace.add_tab(TEST_TAB, Some(destination)); + let panel = PanelPath::builder().end().start().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 2); + assert_eq!(panel.active_index(), 1); + assert_eq!(panel.active_tab().unwrap(), TEST_TAB); +} + +#[test] +fn dockspace_insert_tab() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().end(), + insert_index: Some(1), + edge: None, + }; + workspace.add_tab(TEST_TAB, Some(destination)); + let panel = PanelPath::builder().end().end().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 3); + assert_eq!(panel.active_index(), 1); + assert_eq!(panel.active_tab().unwrap(), TEST_TAB); +} + +#[test] +fn dockspace_insert_tab_empty() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().start(), + insert_index: Some(0), + edge: None, + }; + workspace.add_tab(TEST_TAB, Some(destination)); + let panel = workspace.root.as_division().unwrap().start().as_panel().unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_index(), 0); + assert_eq!(panel.active_tab().unwrap(), TEST_TAB); +} + +#[test] +fn dockspace_add_tab_automatic() { + let mut workspace = test_layout(); + workspace.add_tab(TEST_TAB, None); + let panel = PanelPath::builder().start().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_index(), 0); + assert_eq!(panel.active_tab().unwrap(), TEST_TAB); +} + +#[test] +fn dockspace_insert_tab_edge() { + for (direction, start) in [(Direction::Horizontal, true), (Direction::Horizontal, false), (Direction::Vertical, true), (Direction::Vertical, false)] { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().start(), + insert_index: None, + edge: Some(InsertEdge { direction, start }), + }; + workspace.add_tab(TEST_TAB, Some(destination)); + let division = PanelPath::builder().start().get_wrapped_mut(&mut workspace.root).unwrap().as_division().unwrap(); + assert_eq!(division.direction(), direction); + let [insert, other] = if start { [division.start(), division.end()] } else { [division.end(), division.start()] }; + assert_eq!(other.as_panel().unwrap().len(), 0); + assert_eq!(insert.as_panel().unwrap().active_tab().unwrap(), TEST_TAB); + } +} + +#[test] +fn dockspace_delete_tab() { + let mut workspace = test_layout(); + workspace.delete_tab(TabPath::new(PanelPath::builder().end().end(), 1)); + let panel = PanelPath::builder().end().end().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(11_000)); +} + +#[test] +fn dockspace_delete_replace_active() { + let mut workspace = test_layout(); + workspace.delete_tab(TabPath::new(PanelPath::builder().end().end(), 0)); + let panel = PanelPath::builder().end().end().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(11_001)); +} + +#[test] +fn dockspace_delete_division() { + let mut workspace = test_layout(); + workspace.delete_tab(TabPath::new(PanelPath::builder().end().end(), 0)); + workspace.delete_tab(TabPath::new(PanelPath::builder().end().end(), 0)); + let panel = PanelPath::builder().end().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(1_000)); +} + +#[test] +fn dockspace_delete_2_divisions() { + let mut workspace = test_layout(); + workspace.delete_tab(TabPath::new(PanelPath::builder().end().end(), 0)); + workspace.delete_tab(TabPath::new(PanelPath::builder().end().end(), 0)); + workspace.delete_tab(TabPath::new(PanelPath::builder().end(), 0)); + let panel = PanelPath::builder().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 0); + assert!(panel.active_tab().is_none()); +} + +#[test] +fn dockspace_select_tab() { + let mut workspace = test_layout(); + workspace.select_tab(TabPath::new(PanelPath::builder().end().end(), 1)); + let panel = PanelPath::builder().end().end().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.active_tab().unwrap(), test_tab(11_001)); +} + +#[test] +fn dockspace_move_tab_simple() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().start(), + insert_index: None, + edge: None, + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().end(), 1), destination); + let panel = PanelPath::builder().end().end().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 1); + let panel = PanelPath::builder().end().start().get_panel(&workspace.root).unwrap(); + assert_eq!(panel.len(), 2); + assert_eq!(panel.active_tab().unwrap(), test_tab(11_001)); +} + +#[test] +fn dockspace_move_tab_on_self() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().start(), + insert_index: None, + edge: None, + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().start(), 0), destination); + let panel = PanelPath::builder().end().start().get_panel_mut(&mut workspace.root).unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(1_000)); +} + +#[test] +fn dockspace_move_tab_on_self_stack() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().end(), + insert_index: None, + edge: None, + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().end(), 0), destination); + let panel = PanelPath::builder().end().end().get_panel_mut(&mut workspace.root).unwrap(); + assert_eq!(panel.len(), 2); + assert_eq!(panel.active_index(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(11_000)); +} + +#[test] +fn dockspace_move_tab_delete_divison() { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().end(), + insert_index: Some(1), + edge: None, + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().start(), 0), destination); + let panel = workspace.root.as_division().unwrap().end().as_panel().unwrap(); + assert_eq!(panel.len(), 3); + assert_eq!(panel.active_index(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(1_000)); +} + +#[test] +fn dockspace_move_tab_edge() { + for (direction, start) in [(Direction::Horizontal, true), (Direction::Horizontal, false), (Direction::Vertical, true), (Direction::Vertical, false)] { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().start(), + insert_index: None, + edge: Some(InsertEdge { direction, start }), + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().end(), 0), destination); + let division = workspace.root.as_division().unwrap().end().as_division().unwrap().start().as_division().unwrap(); + assert_eq!(division.direction(), direction); + let start_panel = division.start().as_panel().unwrap(); + let end_panel = division.end().as_panel().unwrap(); + assert_eq!(start_panel.len(), 1); + assert_eq!(end_panel.len(), 1); + assert_eq!(start_panel.active_tab().unwrap(), test_tab(if start { 11_000 } else { 1_000 })); + assert_eq!(end_panel.active_tab().unwrap(), test_tab(if start { 1_000 } else { 11_000 })); + } +} + +#[test] +fn dockspace_move_tab_edge_delete_other() { + for (direction, start) in [(Direction::Horizontal, true), (Direction::Horizontal, false), (Direction::Vertical, true), (Direction::Vertical, false)] { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().end(), + insert_index: None, + edge: Some(InsertEdge { direction, start }), + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().start(), 0), destination); + let division = workspace.root.as_division().unwrap().end().as_division().unwrap(); + assert_eq!(division.direction(), direction); + + let [insert, rest] = if start { [division.start(), division.end()] } else { [division.end(), division.start()] }; + let insert_panel = insert.as_panel().unwrap(); + let rest_panel = rest.as_panel().unwrap(); + assert_eq!(insert_panel.len(), 1); + assert_eq!(rest_panel.len(), 2); + assert_eq!(insert_panel.active_tab().unwrap(), test_tab(1_000)); + assert_eq!(rest_panel.active_tab().unwrap(), test_tab(11_000)); + } +} + +#[test] +fn dockspace_can_not_edge_self() { + for (direction, start) in [(Direction::Horizontal, true), (Direction::Horizontal, false), (Direction::Vertical, true), (Direction::Vertical, false)] { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().start(), + insert_index: None, + edge: Some(InsertEdge { direction, start }), + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().start(), 0), destination); + let panel = workspace.root.as_division().unwrap().end().as_division().unwrap().start().as_panel().unwrap(); + assert_eq!(panel.len(), 1); + assert_eq!(panel.active_tab().unwrap(), test_tab(1_000)); + } +} + +#[test] +fn dockspace_can_edge_self_with_other_tabs() { + for (direction, start) in [(Direction::Horizontal, true), (Direction::Horizontal, false), (Direction::Vertical, true), (Direction::Vertical, false)] { + let mut workspace = test_layout(); + let destination = TabDestination { + panel: PanelPath::builder().end().end(), + insert_index: None, + edge: Some(InsertEdge { direction, start }), + }; + workspace.move_tab(TabPath::new(PanelPath::builder().end().end(), 0), destination); + let division = workspace.root.as_division().unwrap().end().as_division().unwrap().end().as_division().unwrap(); + let [moved, other] = if start { [division.start(), division.end()] } else { [division.end(), division.start()] }; + assert_eq!(moved.as_panel().unwrap().active_tab().unwrap(), test_tab(11_000)); + assert_eq!(other.as_panel().unwrap().active_tab().unwrap(), test_tab(11_001)); } } + +#[test] +fn dockspace_resize() { + let mut workspace = test_layout(); + + workspace.resize_division(PanelPath::builder().end(), 11., 13.); + let division = workspace.root.as_division().unwrap().end().as_division().unwrap(); + assert_eq!(division.size(), [11., 13.]) +} diff --git a/editor/src/messages/workspace/workspace_types.rs b/editor/src/messages/workspace/workspace_types.rs new file mode 100644 index 0000000000..ee6925146d --- /dev/null +++ b/editor/src/messages/workspace/workspace_types.rs @@ -0,0 +1,334 @@ +use crate::messages::frontend::utility_types::FrontendDocumentDetails; +use crate::messages::portfolio::document::utility_types::misc::DocumentViewId; +use crate::messages::workspace::workspace_message::PortfolioMessageHandler; + +use super::workspace_message::DocumentId; + +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(tag = "tabType", content = "tabData")] +pub enum TabType { + Layers, + Properties, + Document { + #[serde(rename = "viewId")] + view_id: DocumentViewId, + #[serde(rename = "documentId")] + document_id: DocumentId, + }, +} +impl TabType { + pub const fn document(view_id: DocumentViewId, document_id: DocumentId) -> Self { + Self::Document { view_id, document_id } + } +} + +/// Represents a path to a panel or division +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct PanelPath { + value: u32, + depth: u32, +} + +impl PanelPath { + /// Construct from a u64 (which is passed to the frontend) + pub const fn new(value: u64) -> Self { + let (value, depth) = ((value >> 32) as u32, value as u32); + Self { value, depth } + } + /// Build this panel path to a u64 for passing to the frontend + pub const fn build(&self) -> u64 { + ((self.value as u64) << 32) + self.depth as u64 + } + /// A builder for a path - can use `.start()` and `.end()` to append in order (starting from the root division) + pub const fn builder() -> Self { + Self { value: 0, depth: 0 } + } + /// Get the parent of this path wrapped up as a [`DivisionOrPanel`] and the start (it should be a division but this isn't checked) + pub fn get_parent_wrapped_mut<'a>(&self, mut root: &'a mut DivisionOrPanel) -> Option<(&'a mut DivisionOrPanel, bool)> { + let last_shift = self.depth.checked_sub(1)?; + for shift in 0..last_shift { + let start = ((self.value >> shift) & 1) == 0; + root = if start { &mut root.as_division_mut()?.start } else { &mut root.as_division_mut()?.end } + } + Some((root, ((self.value >> last_shift) & 1) == 0)) + } + /// Resolve this path to a [`DivisionOrPanel`] (None for invalid paths) + pub fn get_wrapped_mut<'a>(&self, mut root: &'a mut DivisionOrPanel) -> Option<&'a mut DivisionOrPanel> { + for shift in 0..self.depth { + let start = ((self.value >> shift) & 1) == 0; + root = if start { &mut root.as_division_mut()?.start } else { &mut root.as_division_mut()?.end } + } + Some(root) + } + /// Resolve this path to a mutable [`Panel`] (None for divisions or invalid path) + pub fn get_panel_mut<'a>(&self, root: &'a mut DivisionOrPanel) -> Option<&'a mut Panel> { + self.get_wrapped_mut(root).and_then(|wrapped| wrapped.as_panel_mut()) + } + /// Resolve this path to a [`Panel`] (None for divisions or invalid path) + pub fn get_panel<'a>(&self, mut root: &'a DivisionOrPanel) -> Option<&'a Panel> { + for shift in 0..self.depth { + let start = ((self.value >> shift) & 1) == 0; + root = if start { &root.as_division()?.start } else { &root.as_division()?.end } + } + root.as_panel() + } + /// Append a start to the end of this path + pub fn start(mut self) -> Self { + self.depth += 1; + self + } + /// Append an end to the end of this path + pub fn end(mut self) -> Self { + self.value |= 1 << self.depth; + self.depth += 1; + self + } +} + +#[test] +fn build_panel_path() { + let original = PanelPath::builder().end().start().end().end(); + assert_eq!(PanelPath::new(original.build()), original); +} + +/// Represents the path to a tab (including its panel and tab index) +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct TabPath { + pub panel: PanelPath, + pub tab_index: usize, +} + +impl TabPath { + pub fn new(panel: impl Into, tab_index: usize) -> Self { + Self { panel: panel.into(), tab_index } + } +} + +/// The orientation and start bool for inserting at the edge to create a new panel - used in the [`TabDestination`] +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct InsertEdge { + pub direction: Direction, + pub start: bool, +} + +/// Represents the destination for adding or moving a tab +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct TabDestination { + /// The panel for moving to or dividing (if `edge` is set) + pub panel: PanelPath, + /// The insert index if inserting into an existing panel (None adds at the end) + pub insert_index: Option, + /// If set, the panel is split and the tab is inserted into the new panel + pub edge: Option, +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub enum Direction { + Horizontal, + Vertical, +} + +/// A panel consisting of zero or more tabs, one of which is active. +#[derive(Clone, Debug)] +pub struct Panel { + tabs: Vec, + active_index: usize, +} + +impl Panel { + pub fn new(tabs: impl Into>) -> Self { + Self { tabs: tabs.into(), active_index: 0 } + } + pub fn add_tab(&mut self, tab: TabType, index: Option) { + match index { + Some(index) => self.tabs.insert(index, tab), + None => self.tabs.push(tab), + } + self.active_index = index.unwrap_or(self.tabs.len().saturating_sub(1)) + } + pub fn remove_tab(&mut self, index: usize) -> TabType { + self.active_index = self.active_index.min(self.tabs.len().saturating_sub(2)); + self.tabs.remove(index) + } + pub fn is_empty(&self) -> bool { + self.tabs.is_empty() + } + pub fn select_tab(&mut self, index: usize) { + self.active_index = index.min(self.tabs.len().saturating_sub(1)); + } + pub fn len(&self) -> usize { + self.tabs.len() + } + pub fn active_index(&self) -> usize { + self.active_index + } + pub fn active_tab(&self) -> Option { + self.tabs.get(self.active_index).copied() + } +} + +#[derive(Clone, Debug)] +pub enum DivisionOrPanel { + Division(Box), + Panel(Panel), +} + +impl From for DivisionOrPanel { + fn from(value: Division) -> Self { + Self::Division(Box::new(value)) + } +} + +impl From for DivisionOrPanel { + fn from(value: Panel) -> Self { + Self::Panel(value) + } +} + +impl DivisionOrPanel { + pub fn as_division(&self) -> Option<&Division> { + let DivisionOrPanel::Division(division) = self else { return None }; + Some(division) + } + pub fn as_division_mut(&mut self) -> Option<&mut Division> { + let DivisionOrPanel::Division(division) = self else { return None }; + Some(division) + } + pub fn as_panel(&self) -> Option<&Panel> { + let DivisionOrPanel::Panel(panel) = self else { return None }; + Some(panel) + } + pub fn as_panel_mut(&mut self) -> Option<&mut Panel> { + let DivisionOrPanel::Panel(panel) = self else { return None }; + Some(panel) + } + /// If this is a division, then replace itself with either its start or end child (as specified) + pub fn replace_with_child(&mut self, start: bool) { + let Self::Division(division) = self else { return }; + let child = if start { &mut division.start } else { &mut division.end }; + let child = std::mem::replace(child, Panel::new([]).into()); + *self = child; + } + /// Get the first panel by repeatedly getting the start panel of a division; useful for adding tabs with no destination + pub fn first_panel_mut(&mut self) -> &mut Panel { + let mut item = self; + loop { + match item { + DivisionOrPanel::Division(division) => item = &mut division.start, + DivisionOrPanel::Panel(panel) => return panel, + } + } + } +} + +#[derive(Clone, Debug)] +pub struct Division { + direction: Direction, + start_size: f64, + end_size: f64, + start: DivisionOrPanel, + end: DivisionOrPanel, +} + +impl Division { + pub fn new(direction: Direction, start_size: f64, end_size: f64, start: impl Into, end: impl Into) -> Self { + Self { + direction, + start_size, + end_size, + start: start.into(), + end: end.into(), + } + } + pub fn start(&self) -> &DivisionOrPanel { + &self.start + } + pub fn end(&self) -> &DivisionOrPanel { + &self.end + } + pub fn direction(&self) -> Direction { + self.direction + } + pub fn resize(&mut self, start_size: f64, end_size: f64) { + self.start_size = start_size; + self.end_size = end_size; + } + pub fn size(&self) -> [f64; 2] { + [self.start_size, self.end_size] + } +} + +// ------ Frontend ------ // + +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct FrontendDivision { + direction: Direction, + #[serde(rename = "startSize")] + start_size: f64, + #[serde(rename = "endSize")] + end_size: f64, + start: FrontendDivisionOrPanel, + end: FrontendDivisionOrPanel, + identifier: u64, +} +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct FrontendPanel { + tabs: Vec, + #[serde(rename = "activeIndex")] + active_index: usize, + identifier: u64, +} +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub enum FrontendDivisionOrPanel { + Division(Box), + Panel(FrontendPanel), +} + +impl FrontendPanel { + pub fn new(source: &Panel, portfolio: &PortfolioMessageHandler, identifier: PanelPath) -> Self { + Self { + tabs: source.tabs.clone(), + //tabs: source.tabs.iter().map(|source| FrontendTabType::new(source, portfolio)).collect(), + active_index: source.active_index, + identifier: identifier.build(), + } + } +} + +impl FrontendDivisionOrPanel { + pub fn new(source: &DivisionOrPanel, portfolio: &PortfolioMessageHandler, identifier: PanelPath) -> Self { + match source { + DivisionOrPanel::Division(source) => Self::Division(Box::new(FrontendDivision::new(source, portfolio, identifier))), + DivisionOrPanel::Panel(source) => Self::Panel(FrontendPanel::new(source, portfolio, identifier)), + } + } +} + +impl FrontendDivision { + pub fn new(source: &Division, portfolio: &PortfolioMessageHandler, identifier: PanelPath) -> Self { + Self { + start: FrontendDivisionOrPanel::new(&source.start, portfolio, identifier.start()), + end: FrontendDivisionOrPanel::new(&source.end, portfolio, identifier.end()), + direction: source.direction, + start_size: source.start_size, + end_size: source.end_size, + identifier: identifier.build(), + } + } +} +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(tag = "tabType", content = "tabData")] +pub enum FrontendTabType { + Layers, + Properties, + Document(Option), +} +impl FrontendTabType { + pub fn new(source: &TabType, portfolio: &PortfolioMessageHandler) -> Self { + match source { + TabType::Layers => Self::Layers, + TabType::Properties => Self::Properties, + TabType::Document { view_id, .. } => Self::Document(portfolio.frontend_document(*view_id)), + } + } +} diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index 67be1cce66..bbbe3a24aa 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -9,11 +9,14 @@ import { createPanicManager } from "@graphite/io-managers/panic"; import { createPersistenceManager } from "@graphite/io-managers/persistence"; import { createDialogState } from "@graphite/state-providers/dialog"; + import { createDockspaceState } from "@graphite/state-providers/dockspace"; import { createDocumentState } from "@graphite/state-providers/document"; import { createFontsState } from "@graphite/state-providers/fonts"; import { createFullscreenState } from "@graphite/state-providers/fullscreen"; + import { createLayerState } from "@graphite/state-providers/layers"; import { createNodeGraphState } from "@graphite/state-providers/node-graph"; import { createPortfolioState } from "@graphite/state-providers/portfolio"; + import { createPropertiesState } from "@graphite/state-providers/properties"; import { operatingSystem } from "@graphite/utility-functions/platform"; import { type Editor } from "@graphite/wasm-communication/editor"; @@ -26,16 +29,22 @@ // State provider systems let dialog = createDialogState(editor); setContext("dialog", dialog); + let dockspace = createDockspaceState(editor); + setContext("dockspace", dockspace); let document = createDocumentState(editor); setContext("document", document); let fonts = createFontsState(editor); setContext("fonts", fonts); let fullscreen = createFullscreenState(editor); setContext("fullscreen", fullscreen); + let layers = createLayerState(editor); + setContext("layers", layers); let nodeGraph = createNodeGraphState(editor); setContext("nodeGraph", nodeGraph); let portfolio = createPortfolioState(editor); setContext("portfolio", portfolio); + let properties = createPropertiesState(editor); + setContext("properties", properties); // Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.) createClipboardManager(editor); diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 50a6e645fa..6aa73927ca 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -1,25 +1,13 @@ - - {#if !$document.graphViewOverlayOpen} - - + + {#if !$documentView.graphViewOverlayOpen} + + - + {:else} - + {/if} - {#if !$document.graphViewOverlayOpen} + {#if !$documentView.graphViewOverlayOpen} - + {:else} {/if} - + - {#if rulersVisible} + {#if $documentView.rulers.visible} - + {/if} - {#if rulersVisible} + {#if $documentView.rulers.visible} - + {/if} @@ -493,15 +430,15 @@ -
+
panCanvasY(detail)} on:pressTrack={({ detail }) => pageY(detail)} /> @@ -510,8 +447,8 @@ panCanvasX(detail)} on:pressTrack={({ detail }) => pageX(detail)} on:pointerup={() => editor.handle.setGridAlignedEdges()} diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 4dbbe1e46f..e9f0997290 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -1,12 +1,11 @@ - (dragInPanel = false)}> + (dragInPanel = false) && (isDraggingLayer = false)}> - + deselectAllLayers()} on:dragover={(e) => draggable && updateInsertLine(e)} on:dragend={() => draggable && drop()}> - {#each layers as listing, index} + {#each $layersState.layers as listing, index} - import { getContext, onMount } from "svelte"; + import { getContext } from "svelte"; - import type { Editor } from "@graphite/wasm-communication/editor"; - import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@graphite/wasm-communication/messages"; + import type { PropertiesState } from "@graphite/state-providers/properties"; import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; import LayoutRow from "@graphite/components/layout/LayoutRow.svelte"; import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte"; - const editor = getContext("editor"); - - let propertiesOptionsLayout = defaultWidgetLayout(); - let propertiesSectionsLayout = defaultWidgetLayout(); - - onMount(() => { - editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => { - patchWidgetLayout(propertiesOptionsLayout, updatePropertyPanelOptionsLayout); - propertiesOptionsLayout = propertiesOptionsLayout; - }); - - editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => { - patchWidgetLayout(propertiesSectionsLayout, updatePropertyPanelSectionsLayout); - propertiesSectionsLayout = propertiesSectionsLayout; - }); - }); + const properties = getContext("properties"); - + - + diff --git a/frontend/src/components/window/workspace/Division.svelte b/frontend/src/components/window/workspace/Division.svelte new file mode 100644 index 0000000000..a02193cfc9 --- /dev/null +++ b/frontend/src/components/window/workspace/Division.svelte @@ -0,0 +1,94 @@ + + + + + + + + + + + + + diff --git a/frontend/src/components/window/workspace/Panel.svelte b/frontend/src/components/window/workspace/Panel.svelte index 9b2fe7cb11..bb4c76002d 100644 --- a/frontend/src/components/window/workspace/Panel.svelte +++ b/frontend/src/components/window/workspace/Panel.svelte @@ -1,44 +1,44 @@ - - - panelType && editor.handle.setActivePanel(panelType)}> + tabType && editor.handle.setActivePanel(tabType)}> - + {#each tabLabels as tabLabel, tabIndex} { e.stopPropagation(); - clickAction?.(tabIndex); + editor.handle.selectTab(panelIdentifier, tabIndex); }} on:auxclick={(e) => { // Middle mouse button click if (e.button === 1) { e.stopPropagation(); - closeAction?.(tabIndex); + editor.handle.deleteTab(panelIdentifier, tabIndex); } }} on:mouseup={(e) => { @@ -82,17 +82,21 @@ // A possible future improvement could save the target element during mousedown and check if it's the same here. if (!isEventSupported("auxclick") && e.button === 1) { e.stopPropagation(); - closeAction?.(tabIndex); + editor.handle.deleteTab(panelIdentifier, tabIndex); } }} bind:this={tabElements[tabIndex]} + draggable + on:dragstart={() => dockspace.startDragging(panelIdentifier, tabIndex)} + on:dragend={dockspace.endDragging} + data-tab-index={tabIndex} > {tabLabel.name} {#if tabCloseButtons} { e?.stopPropagation(); - closeAction?.(tabIndex); + editor.handle.deleteTab(panelIdentifier, tabIndex); }} icon="CloseX" size={16} @@ -106,10 +110,12 @@ Coming soon --> - - {#if panelType} - - {:else} + + {#if tabData !== undefined} + + {:else if tabType !== undefined && SIMPLE_TABS.get(tabType) !== undefined} + + {:else if tabType === undefined} @@ -142,6 +148,8 @@ + {:else} + Invalid tab {tabType} {/if} diff --git a/frontend/src/components/window/workspace/SubdivisionOrPanel.svelte b/frontend/src/components/window/workspace/SubdivisionOrPanel.svelte new file mode 100644 index 0000000000..b0043e3e4d --- /dev/null +++ b/frontend/src/components/window/workspace/SubdivisionOrPanel.svelte @@ -0,0 +1,44 @@ + + +{#if "Division" in value} + +{:else} + tabLabel(tab, $portfolio.documents))} + tabActiveIndex={Number(value.Panel.activeIndex)} + /> +{/if} diff --git a/frontend/src/components/window/workspace/Workspace.svelte b/frontend/src/components/window/workspace/Workspace.svelte index 584e4664d2..7e20d399da 100644 --- a/frontend/src/components/window/workspace/Workspace.svelte +++ b/frontend/src/components/window/workspace/Workspace.svelte @@ -1,156 +1,158 @@ - - - - - 0 ? "Document" : undefined} - tabCloseButtons={true} - tabMinWidths={true} - tabLabels={documentTabLabels} - clickAction={(tabIndex) => editor.handle.selectDocument($portfolio.documents[tabIndex].id)} - closeAction={(tabIndex) => editor.handle.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)} - tabActiveIndex={$portfolio.activeDocumentIndex} - bind:this={documentPanel} - /> - - - resizePanel(e)} /> - - - - - resizePanel(e)} /> - - - - - + (dragTarget = undefined)}> + {#if $dockspace.divisionData !== undefined} + + {/if} {#if $dialog.visible} {/if} +{#if $dockspace.tabDragging !== undefined && dragTarget !== undefined} +
+{/if} +{#if $dockspace.tabDragging !== undefined && dragState?.insert !== undefined} +
+{/if} diff --git a/frontend/src/state-providers/dockspace.ts b/frontend/src/state-providers/dockspace.ts new file mode 100644 index 0000000000..0bcc2f3b11 --- /dev/null +++ b/frontend/src/state-providers/dockspace.ts @@ -0,0 +1,50 @@ +import { writable } from "svelte/store"; + +import type { Editor } from "@graphite/wasm-communication/editor"; + +import { UpdateDockspace, type DivisionOrPanel } from "@graphite/wasm-communication/messages"; + +export type TabType = string; + +export const MIN_PANEL_SIZE = 100; + +export type PanelIdentifier = bigint; + +export type TabDragging = { panel: PanelIdentifier; tabIndex: number }; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createDockspaceState(editor: Editor) { + const state = writable({ + divisionData: undefined as undefined | DivisionOrPanel, + tabDragging: undefined as undefined | TabDragging, + }); + const { subscribe, update } = state; + + editor.subscriptions.subscribeJsMessage(UpdateDockspace, (updateDockspace) => + update((state) => { + state.divisionData = updateDockspace.root; + return state; + }), + ); + + const startDragging = (panel: PanelIdentifier, tabIndex: number) => { + update((state) => { + state.tabDragging = { panel, tabIndex }; + return state; + }); + }; + + const endDragging = () => { + update((state) => { + state.tabDragging = undefined; + return state; + }); + }; + + return { + subscribe, + startDragging, + endDragging, + }; +} +export type DockspaceState = ReturnType; diff --git a/frontend/src/state-providers/document.ts b/frontend/src/state-providers/document.ts index 4aa982dd36..6d248dbb69 100644 --- a/frontend/src/state-providers/document.ts +++ b/frontend/src/state-providers/document.ts @@ -1,7 +1,8 @@ import { tick } from "svelte"; -import { writable } from "svelte/store"; +import { writable, type Readable, type Updater, type Writable } from "svelte/store"; import { type Editor } from "@graphite/wasm-communication/editor"; +import type { Color } from "@graphite/wasm-communication/messages"; import { defaultWidgetLayout, patchWidgetLayout, @@ -13,28 +14,70 @@ import { UpdateNodeGraphBarLayout, TriggerGraphViewOverlay, TriggerDelayedZoomCanvasToFitAll, + type DocumentViewId, + UpdateDocumentArtwork, + UpdateEyedropperSamplingState, + UpdateDocumentScrollbars, + UpdateDocumentRulers, + UpdateMouseCursor, + DisplayEditableTextbox, + TriggerTextCommit, + DisplayEditableTextboxTransform, + DisplayRemoveEditableTextbox, + type XY, + type MouseCursorIcon, } from "@graphite/wasm-communication/messages"; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createDocumentState(editor: Editor) { - const state = writable({ - // Layouts - documentModeLayout: defaultWidgetLayout(), - toolOptionsLayout: defaultWidgetLayout(), - documentBarLayout: defaultWidgetLayout(), - toolShelfLayout: defaultWidgetLayout(), - workingColorsLayout: defaultWidgetLayout(), - nodeGraphBarLayout: defaultWidgetLayout(), - // Graph view overlay - graphViewOverlayOpen: false, - }); - const { subscribe, update } = state; +export type EyedropperState = { + mousePosition: XY | undefined; + primaryColor: string; + secondaryColor: string; + setColorChoice: "Primary" | "Secondary" | undefined; +}; - // Update layouts +export type TextInput = { + text: string; + lineWidth: undefined | number; + fontSize: number; + color: Color; + url: string; + transform: number[]; +}; +const DEFAULT_VIEW_DATA = { + // Layouts + documentModeLayout: defaultWidgetLayout(), + toolOptionsLayout: defaultWidgetLayout(), + documentBarLayout: defaultWidgetLayout(), + toolShelfLayout: defaultWidgetLayout(), + workingColorsLayout: defaultWidgetLayout(), + nodeGraphBarLayout: defaultWidgetLayout(), + // Graph view overlay + graphViewOverlayOpen: false, + + artwork: "", + eyedropperSamplingState: undefined as undefined | EyedropperState, + scrollbar: { + position: { x: 0.5, y: 0.5 } satisfies XY, + size: { x: 0.5, y: 0.5 } satisfies XY, + multiplier: { x: 0, y: 0 } satisfies XY, + }, + rulers: { + origin: { x: 0, y: 0 } satisfies XY, + spacing: 100, + interval: 100, + visible: true, + }, + cursor: "default" as MouseCursorIcon, + textInput: undefined as undefined | TextInput, +}; + +const view = 41n; + +function updateLayouts(editor: Editor, state: Writable) { editor.subscriptions.subscribeJsMessage(UpdateDocumentModeLayout, async (updateDocumentModeLayout) => { await tick(); - update((state) => { + updateView(state, view, (state) => { // `state.documentModeLayout` is mutated in the function patchWidgetLayout(state.documentModeLayout, updateDocumentModeLayout); return state; @@ -43,7 +86,7 @@ export function createDocumentState(editor: Editor) { editor.subscriptions.subscribeJsMessage(UpdateToolOptionsLayout, async (updateToolOptionsLayout) => { await tick(); - update((state) => { + updateView(state, view, (state) => { // `state.documentModeLayout` is mutated in the function patchWidgetLayout(state.toolOptionsLayout, updateToolOptionsLayout); return state; @@ -52,7 +95,7 @@ export function createDocumentState(editor: Editor) { editor.subscriptions.subscribeJsMessage(UpdateDocumentBarLayout, async (updateDocumentBarLayout) => { await tick(); - update((state) => { + updateView(state, view, (state) => { // `state.documentModeLayout` is mutated in the function patchWidgetLayout(state.documentBarLayout, updateDocumentBarLayout); return state; @@ -61,7 +104,7 @@ export function createDocumentState(editor: Editor) { editor.subscriptions.subscribeJsMessage(UpdateToolShelfLayout, async (updateToolShelfLayout) => { await tick(); - update((state) => { + updateView(state, view, (state) => { // `state.documentModeLayout` is mutated in the function patchWidgetLayout(state.toolShelfLayout, updateToolShelfLayout); return state; @@ -70,22 +113,50 @@ export function createDocumentState(editor: Editor) { editor.subscriptions.subscribeJsMessage(UpdateWorkingColorsLayout, async (updateWorkingColorsLayout) => { await tick(); - update((state) => { + updateView(state, view, (state) => { // `state.documentModeLayout` is mutated in the function patchWidgetLayout(state.workingColorsLayout, updateWorkingColorsLayout); return state; }); }); editor.subscriptions.subscribeJsMessage(UpdateNodeGraphBarLayout, (updateNodeGraphBarLayout) => { - update((state) => { + updateView(state, view, (state) => { patchWidgetLayout(state.nodeGraphBarLayout, updateNodeGraphBarLayout); return state; }); }); +} + +const DEFAULT_DOCUMENT_STATE = { documentViews: new Map>() }; + +function updateView(state: Writable, viewId: DocumentViewId, updater: Updater) { + let run = false; + state.subscribe((state) => { + const view = state.documentViews.get(viewId); + if (view) { + run = true; + view.update(updater); + } + })(); + if (!run) + state.update((state) => { + const view = writable(DEFAULT_VIEW_DATA); + view.update(updater); + state.documentViews.set(viewId, view); + return state; + }); +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createDocumentState(editor: Editor) { + const state = writable(DEFAULT_DOCUMENT_STATE); + + // Update layouts + updateLayouts(editor, state); // Show or hide the graph view overlay editor.subscriptions.subscribeJsMessage(TriggerGraphViewOverlay, (triggerGraphViewOverlay) => { - update((state) => { + updateView(state, view, (state) => { state.graphViewOverlayOpen = triggerGraphViewOverlay.open; return state; }); @@ -94,8 +165,82 @@ export function createDocumentState(editor: Editor) { setTimeout(() => editor.handle.zoomCanvasToFitAll(), 0); }); + // Update rendered SVGs + editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => { + updateView(state, view, (state) => { + state.artwork = data.svg; + return state; + }); + }); + editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (data) => { + updateView(state, view, (state) => { + state.eyedropperSamplingState = data; + return state; + }); + }); + + // Update scrollbars and rulers + editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (data) => { + updateView(state, view, (state) => { + state.scrollbar = data; + return state; + }); + }); + editor.subscriptions.subscribeJsMessage(UpdateDocumentRulers, async (data) => { + updateView(state, view, (state) => { + state.rulers = data; + return state; + }); + }); + + // Update mouse cursor icon + editor.subscriptions.subscribeJsMessage(UpdateMouseCursor, async (data) => { + updateView(state, view, (state) => { + state.cursor = data.cursor; + return state; + }); + }); + + // Text entry + editor.subscriptions.subscribeJsMessage(TriggerTextCommit, async () => { + window.dispatchEvent(new CustomEvent("triggerTextCommit", { detail: view })); + }); + editor.subscriptions.subscribeJsMessage(DisplayEditableTextbox, async (data) => { + updateView(state, view, (state) => { + state.textInput = data; + return state; + }); + }); + editor.subscriptions.subscribeJsMessage(DisplayEditableTextboxTransform, async (data) => { + updateView(state, view, (state) => { + if (state.textInput !== undefined) state.textInput.transform = data.transform; + return state; + }); + }); + editor.subscriptions.subscribeJsMessage(DisplayRemoveEditableTextbox, async () => { + updateView(state, view, (state) => { + state.textInput = undefined; + return state; + }); + }); + return { - subscribe, + subscribe: state.subscribe, + getView: (viewId: DocumentViewId) => { + let view: Readable | undefined = undefined; + state.subscribe((state) => { + view = state.documentViews.get(viewId); + })(); + if (view === undefined) { + const newView = writable(DEFAULT_VIEW_DATA); + state.update((state) => { + state.documentViews.set(viewId, newView); + return state; + }); + view = newView; + } + return view; + }, }; } export type DocumentState = ReturnType; diff --git a/frontend/src/state-providers/layers.ts b/frontend/src/state-providers/layers.ts new file mode 100644 index 0000000000..2eed3d9ded --- /dev/null +++ b/frontend/src/state-providers/layers.ts @@ -0,0 +1,155 @@ +import { writable } from "svelte/store"; + +import type { Editor } from "@graphite/wasm-communication/editor"; +import type { LayerPanelEntry } from "@graphite/wasm-communication/messages"; +import { + defaultWidgetLayout, + patchWidgetLayout, + UpdateDocumentLayerDetails, + UpdateDocumentLayerStructureJs, + UpdateLayersPanelOptionsLayout, + type DataBuffer, +} from "@graphite/wasm-communication/messages"; + +type DocumentLayerStructure = { + layerId: bigint; + children: DocumentLayerStructure[]; +}; + +export type LayerListingInfo = { + folderIndex: number; + bottomLayer: boolean; + editingName: boolean; + entry: LayerPanelEntry; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createLayerState(editor: Editor) { + const { subscribe, update } = writable({ + layersPanelOptionsLayout: defaultWidgetLayout(), + // Layer data + layerCache: new Map(), // TODO: replace with BigUint64Array as index + layers: [] as LayerListingInfo[], + }); + + function newUpdateDocumentLayerStructure(dataBuffer: DataBuffer): DocumentLayerStructure { + const pointerNum = Number(dataBuffer.pointer); + const lengthNum = Number(dataBuffer.length); + + const wasmMemoryBuffer = editor.raw.buffer; + + // Decode the folder structure encoding + const encoding = new DataView(wasmMemoryBuffer, pointerNum, lengthNum); + + // The structure section indicates how to read through the upcoming layer list and assign depths to each layer + const structureSectionLength = Number(encoding.getBigUint64(0, true)); + const structureSectionMsbSigned = new DataView(wasmMemoryBuffer, pointerNum + 8, structureSectionLength * 8); + + // The layer IDs section lists each layer ID sequentially in the tree, as it will show up in the panel + const layerIdsSection = new DataView(wasmMemoryBuffer, pointerNum + 8 + structureSectionLength * 8); + + let layersEncountered = 0; + let currentFolder: DocumentLayerStructure = { layerId: BigInt(-1), children: [] }; + const currentFolderStack = [currentFolder]; + + for (let i = 0; i < structureSectionLength; i += 1) { + const msbSigned = structureSectionMsbSigned.getBigUint64(i * 8, true); + const msbMask = BigInt(1) << BigInt(64 - 1); + + // Set the MSB to 0 to clear the sign and then read the number as usual + const numberOfLayersAtThisDepth = msbSigned & ~msbMask; + + // Store child folders in the current folder (until we are interrupted by an indent) + for (let j = 0; j < numberOfLayersAtThisDepth; j += 1) { + const layerId = layerIdsSection.getBigUint64(layersEncountered * 8, true); + layersEncountered += 1; + + const childLayer: DocumentLayerStructure = { layerId, children: [] }; + currentFolder.children.push(childLayer); + } + + // Check the sign of the MSB, where a 1 is a negative (outward) indent + const subsequentDirectionOfDepthChange = (msbSigned & msbMask) === BigInt(0); + // Inward + if (subsequentDirectionOfDepthChange) { + currentFolderStack.push(currentFolder); + currentFolder = currentFolder.children[currentFolder.children.length - 1]; + } + // Outward + else { + const popped = currentFolderStack.pop(); + if (!popped) throw Error("Too many negative indents in the folder structure"); + if (popped) currentFolder = popped; + } + } + + return currentFolder; + } + + function rebuildLayerHierarchy(updateDocumentLayerStructure: DocumentLayerStructure) { + update((state) => { + const layerWithNameBeingEdited = state.layers.find((layer: LayerListingInfo) => layer.editingName); + const layerIdWithNameBeingEdited = layerWithNameBeingEdited?.entry.id; + + // Clear the layer hierarchy before rebuilding it + state.layers = []; + + // Build the new layer hierarchy + const recurse = (folder: DocumentLayerStructure) => { + folder.children.forEach((item, index) => { + const mapping = state.layerCache.get(String(item.layerId)); + if (mapping) { + mapping.id = item.layerId; + state.layers.push({ + folderIndex: index, + bottomLayer: index === folder.children.length - 1, + entry: mapping, + editingName: layerIdWithNameBeingEdited === item.layerId, + }); + } + + // Call self recursively if there are any children + if (item.children.length >= 1) recurse(item); + }); + }; + recurse(updateDocumentLayerStructure); + return state; + }); + } + + function updateLayerInTree(targetId: bigint, targetLayer: LayerPanelEntry) { + update((state) => { + state.layerCache.set(String(targetId), targetLayer); + + const layer = state.layers.find((layer: LayerListingInfo) => layer.entry.id === targetId); + if (layer) { + layer.entry = targetLayer; + } + return state; + }); + } + + editor.subscriptions.subscribeJsMessage(UpdateLayersPanelOptionsLayout, (updateLayersPanelOptionsLayout) => { + update((state) => { + patchWidgetLayout(state.layersPanelOptionsLayout, updateLayersPanelOptionsLayout); + return state; + }); + }); + + editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerStructureJs, (updateDocumentLayerStructure) => { + const structure = newUpdateDocumentLayerStructure(updateDocumentLayerStructure.dataBuffer); + rebuildLayerHierarchy(structure); + }); + + editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => { + const targetLayer = updateDocumentLayerDetails.data; + const targetId = targetLayer.id; + + updateLayerInTree(targetId, targetLayer); + }); + + return { + subscribe, + }; +} +export type LayersState = ReturnType; diff --git a/frontend/src/state-providers/properties.ts b/frontend/src/state-providers/properties.ts new file mode 100644 index 0000000000..316bb035ca --- /dev/null +++ b/frontend/src/state-providers/properties.ts @@ -0,0 +1,31 @@ +import { writable } from "svelte/store"; + +import type { Editor } from "@graphite/wasm-communication/editor"; +import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@graphite/wasm-communication/messages"; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createPropertiesState(editor: Editor) { + const { subscribe, update } = writable({ + propertiesOptionsLayout: defaultWidgetLayout(), + propertiesSectionsLayout: defaultWidgetLayout(), + }); + + editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => { + update((state) => { + patchWidgetLayout(state.propertiesOptionsLayout, updatePropertyPanelOptionsLayout); + return state; + }); + }); + + editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => { + update((state) => { + patchWidgetLayout(state.propertiesSectionsLayout, updatePropertyPanelSectionsLayout); + return state; + }); + }); + + return { + subscribe, + }; +} +export type PropertiesState = ReturnType; diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index 16f03f1a6e..4e3b974fd0 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -3,6 +3,8 @@ import { Transform, Type, plainToClass } from "class-transformer"; +import type { PanelIdentifier, TabType } from "@graphite/state-providers/dockspace"; + import { type PopoverButtonStyle, type IconName, type IconSize } from "@graphite/utility-functions/icons"; import { type EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js"; @@ -743,6 +745,37 @@ export class UpdateEyedropperSamplingState extends JsMessage { readonly setColorChoice!: "Primary" | "Secondary" | undefined; } +export type DocumentViewId = bigint; +export type DocumentId = bigint; +export class DocumentTabData { + readonly viewId!: DocumentViewId; + readonly documentId!: DocumentId; +} +export type TabData = DocumentTabData | undefined; +export class Tab { + readonly tabType!: TabType; + readonly tabData?: TabData; +} +export class Panel { + readonly tabs!: Tab[]; + readonly activeIndex!: number; + readonly identifier!: PanelIdentifier; +} +export type Direction = "Horizontal" | "Vertical"; +export class Division { + readonly direction!: Direction; + readonly start!: DivisionOrPanel; + readonly end!: DivisionOrPanel; + readonly startSize!: number; + readonly endSize!: number; + readonly identifier!: PanelIdentifier; +} +export type DivisionOrPanel = { Division: Division } | { Panel: Panel }; + +export class UpdateDockspace extends JsMessage { + readonly root!: DivisionOrPanel; +} + const mouseCursorIconCSSNames = { Default: "default", None: "none", @@ -1604,6 +1637,7 @@ export const messageMakers: Record = { UpdateDialogButtons, UpdateDialogColumn1, UpdateDialogColumn2, + UpdateDockspace, UpdateDocumentArtwork, UpdateDocumentBarLayout, UpdateDocumentLayerDetails, diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index bcca50ea91..e67c6dd5ac 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -17,6 +17,7 @@ use editor::messages::portfolio::document::utility_types::network_interface::Nod use editor::messages::portfolio::utility_types::Platform; use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::WidgetId; +use editor::messages::workspace::*; use graph_craft::document::NodeId; use graphene_core::raster::color::Color; @@ -661,6 +662,52 @@ impl EditorHandle { self.dispatch(message); } + /// Delete a tab + #[wasm_bindgen(js_name = deleteTab)] + pub fn delete_tab(&self, panel_path: u64, tab_index: usize) { + let tab = TabPath::new(PanelPath::new(panel_path), tab_index); + self.dispatch(WorkspaceMessage::DeleteTab { tab }); + } + + /// Move a tab + #[wasm_bindgen(js_name = moveTab)] + pub fn move_tab(&self, source_panel_path: u64, source_tab_index: usize, target_panel_path: u64, insert_index: Option, horizontal: Option, start: Option) { + let source = TabPath::new(PanelPath::new(source_panel_path), source_tab_index); + let edge = horizontal.and_then(|horizontal| { + start.map(|start| InsertEdge { + direction: if horizontal { Direction::Horizontal } else { Direction::Vertical }, + start, + }) + }); + let panel = PanelPath::new(target_panel_path); + let destination = TabDestination { panel, insert_index, edge }; + self.dispatch(WorkspaceMessage::MoveTab { source, destination }); + } + + /// Select a tab + #[wasm_bindgen(js_name = selectTab)] + pub fn select_tab(&self, panel_path: u64, tab_index: usize) { + let tab = TabPath::new(PanelPath::new(panel_path), tab_index); + self.dispatch(WorkspaceMessage::SelectTab { tab }); + } + + /// Resize a division + #[wasm_bindgen(js_name = resizeDivision)] + pub fn resize_division(&self, divison_path: u64, start_size: f64, end_size: f64) { + let division = PanelPath::new(divison_path); + self.dispatch(WorkspaceMessage::ResizeDivision { division, start_size, end_size }); + } + + /// Is single tab + #[wasm_bindgen(js_name = isSingleTab)] + pub fn is_single_tab(&self, panel_path: u64) -> bool { + if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) { + return true; + } + let panel = PanelPath::new(panel_path); + editor(|editor| editor.dispatcher.message_handlers.workspace_message_handler.is_single_tab(panel)) + } + #[wasm_bindgen(js_name = injectImaginatePollServerStatus)] pub fn inject_imaginate_poll_server_status(&self) { self.dispatch(PortfolioMessage::ImaginatePollServerStatus);