diff --git a/Cargo.lock b/Cargo.lock index d90f78c7..8c00ccef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2271,6 +2271,7 @@ dependencies = [ "open", "rfd", "ron", + "serde", "thiserror 2.0.16", "tracing", "tracing-subscriber", @@ -2298,6 +2299,7 @@ dependencies = [ "graphite-editor", "image", "ron", + "serde", "thiserror 2.0.16", "tracing", "vello", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 0ed348e8..039fe132 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -41,6 +41,7 @@ vello = { workspace = true } derivative = { workspace = true } rfd = { workspace = true } open = { workspace = true } +serde = { workspace = true } # Hardware acceleration dependencies ash = { version = "0.38", optional = true } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index ccae307d..bec70c35 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -1,6 +1,7 @@ use crate::CustomEvent; use crate::cef::WindowSize; use crate::consts::{APP_NAME, CEF_MESSAGE_LOOP_MAX_ITERATIONS}; +use crate::persist::PersistentData; use crate::render::GraphicsState; use graphite_desktop_wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, Platform}; use graphite_desktop_wrapper::{DesktopWrapper, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; @@ -37,6 +38,7 @@ pub(crate) struct WinitApp { start_render_sender: SyncSender<()>, web_communication_initialized: bool, web_communication_startup_buffer: Vec>, + persistent_data: PersistentData, } impl WinitApp { @@ -51,6 +53,9 @@ impl WinitApp { } }); + let mut persistent_data = PersistentData::default(); + persistent_data.load_from_disk(); + Self { cef_context, window: None, @@ -65,6 +70,7 @@ impl WinitApp { start_render_sender, web_communication_initialized: false, web_communication_startup_buffer: Vec::new(), + persistent_data, } } @@ -161,6 +167,53 @@ impl WinitApp { DesktopFrontendMessage::CloseWindow => { let _ = self.event_loop_proxy.send_event(CustomEvent::CloseWindow); } + DesktopFrontendMessage::PersistenceWriteDocument { id, document } => { + self.persistent_data.write_document(id, document); + } + DesktopFrontendMessage::PersistenceDeleteDocument { id } => { + self.persistent_data.delete_document(&id); + } + DesktopFrontendMessage::PersistenceUpdateCurrentDocument { id } => { + self.persistent_data.set_current_document(id); + } + DesktopFrontendMessage::PersistenceUpdateDocumentsList { ids } => { + self.persistent_data.set_document_order(ids); + } + DesktopFrontendMessage::PersistenceLoadCurrentDocument => { + if let Some((id, document)) = self.persistent_data.current_document() { + let message = DesktopWrapperMessage::LoadDocument { + id, + document, + to_front: false, + select_after_open: true, + }; + self.dispatch_desktop_wrapper_message(message); + } + } + DesktopFrontendMessage::PersistenceLoadRemainingDocuments => { + for (id, document) in self.persistent_data.documents_before_current().into_iter().rev() { + let message = DesktopWrapperMessage::LoadDocument { + id, + document, + to_front: true, + select_after_open: false, + }; + self.dispatch_desktop_wrapper_message(message); + } + for (id, document) in self.persistent_data.documents_after_current() { + let message = DesktopWrapperMessage::LoadDocument { + id, + document, + to_front: false, + select_after_open: false, + }; + self.dispatch_desktop_wrapper_message(message); + } + if let Some(id) = self.persistent_data.current_document_id() { + let message = DesktopWrapperMessage::SelectDocument { id }; + self.dispatch_desktop_wrapper_message(message); + } + } } } @@ -307,7 +360,7 @@ impl ApplicationHandler for WinitApp { } WindowEvent::RedrawRequested => { let Some(ref mut graphics_state) = self.graphics_state else { return }; - // Only rerender once we have a new ui texture to display + // Only rerender once we have a new UI texture to display if let Some(window) = &self.window { match graphics_state.render(window.as_ref()) { Ok(_) => {} diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index 3babf730..ee241ba6 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -1,6 +1,7 @@ pub(crate) static APP_NAME: &str = "Graphite"; pub(crate) static APP_ID: &str = "rs.graphite.GraphiteEditor"; pub(crate) static APP_DIRECTORY_NAME: &str = "graphite-editor"; +pub(crate) static APP_AUTOSAVE_DIRECTORY_NAME: &str = "documents"; // CEF configuration constants pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60; diff --git a/desktop/src/dirs.rs b/desktop/src/dirs.rs index 6a964e01..af693efb 100644 --- a/desktop/src/dirs.rs +++ b/desktop/src/dirs.rs @@ -1,7 +1,7 @@ use std::fs::create_dir_all; use std::path::PathBuf; -use crate::consts::APP_DIRECTORY_NAME; +use crate::consts::{APP_AUTOSAVE_DIRECTORY_NAME, APP_DIRECTORY_NAME}; pub(crate) fn ensure_dir_exists(path: &PathBuf) { if !path.exists() { @@ -14,3 +14,9 @@ pub(crate) fn graphite_data_dir() -> PathBuf { ensure_dir_exists(&path); path } + +pub(crate) fn graphite_autosave_documents_dir() -> PathBuf { + let path = graphite_data_dir().join(APP_AUTOSAVE_DIRECTORY_NAME); + ensure_dir_exists(&path); + path +} diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 080d05b6..f4ed4901 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -15,6 +15,7 @@ mod app; use app::WinitApp; mod dirs; +mod persist; use graphite_desktop_wrapper::messages::DesktopWrapperMessage; use graphite_desktop_wrapper::{NodeGraphExecutionResult, WgpuContext}; diff --git a/desktop/src/persist.rs b/desktop/src/persist.rs new file mode 100644 index 00000000..6b4e6590 --- /dev/null +++ b/desktop/src/persist.rs @@ -0,0 +1,192 @@ +use graphite_desktop_wrapper::messages::{Document, DocumentId}; + +#[derive(Default, serde::Serialize, serde::Deserialize)] +pub(crate) struct PersistentData { + documents: DocumentStore, + current_document: Option, + #[serde(skip)] + document_order: Option>, +} + +impl PersistentData { + pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) { + self.documents.write(id, document); + if let Some(order) = &self.document_order { + self.documents.force_order(order.clone()); + } + self.flush(); + } + + pub(crate) fn delete_document(&mut self, id: &DocumentId) { + if Some(*id) == self.current_document { + self.current_document = None; + } + self.documents.delete(id); + self.flush(); + } + + pub(crate) fn current_document_id(&self) -> Option { + match self.current_document { + Some(id) => Some(id), + None => Some(*self.documents.document_ids().first()?), + } + } + + pub(crate) fn current_document(&self) -> Option<(DocumentId, Document)> { + let current_id = self.current_document_id()?; + Some((current_id, self.documents.read(¤t_id)?)) + } + + pub(crate) fn documents_before_current(&self) -> Vec<(DocumentId, Document)> { + let Some(current_id) = self.current_document_id() else { + return Vec::new(); + }; + self.documents + .document_ids() + .into_iter() + .take_while(|id| *id != current_id) + .filter_map(|id| Some((id, self.documents.read(&id)?))) + .collect() + } + + pub(crate) fn documents_after_current(&self) -> Vec<(DocumentId, Document)> { + let Some(current_id) = self.current_document_id() else { + return Vec::new(); + }; + self.documents + .document_ids() + .into_iter() + .skip_while(|id| *id != current_id) + .skip(1) + .filter_map(|id| Some((id, self.documents.read(&id)?))) + .collect() + } + + pub(crate) fn set_current_document(&mut self, id: DocumentId) { + self.current_document = Some(id); + self.flush(); + } + + pub(crate) fn set_document_order(&mut self, order: Vec) { + self.document_order = Some(order); + self.flush(); + } + + fn flush(&self) { + let data = match ron::to_string(self) { + Ok(d) => d, + Err(e) => { + tracing::error!("Failed to serialize persistent data: {e}"); + return; + } + }; + if let Err(e) = std::fs::write(Self::persistence_file_path(), data) { + tracing::error!("Failed to write persistent data to disk: {e}"); + } + } + + pub(crate) fn load_from_disk(&mut self) { + let path = Self::persistence_file_path(); + let data = match std::fs::read_to_string(&path) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::info!("No persistent data file found at {path:?}, starting fresh"); + return; + } + Err(e) => { + tracing::error!("Failed to read persistent data from disk: {e}"); + return; + } + }; + let loaded = match ron::from_str(&data) { + Ok(d) => d, + Err(e) => { + tracing::error!("Failed to deserialize persistent data: {e}"); + return; + } + }; + *self = loaded; + } + + fn persistence_file_path() -> std::path::PathBuf { + let mut path = crate::dirs::graphite_data_dir(); + path.push(format!("{}.ron", crate::consts::APP_AUTOSAVE_DIRECTORY_NAME)); + path + } +} + +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct DocumentStore(Vec); +impl DocumentStore { + fn write(&mut self, id: DocumentId, document: Document) { + let meta = DocumentInfo::new(id, &document); + if let Some(existing) = self.0.iter_mut().find(|meta| meta.id == id) { + *existing = meta; + } else { + self.0.push(meta); + } + if let Err(e) = std::fs::write(Self::document_path(&id), document.content) { + tracing::error!("Failed to write document {id:?} to disk: {e}"); + } + } + + fn delete(&mut self, id: &DocumentId) { + self.0.retain(|meta| meta.id != *id); + if let Err(e) = std::fs::remove_file(Self::document_path(id)) { + tracing::error!("Failed to delete document {id:?} from disk: {e}"); + } + } + + fn read(&self, id: &DocumentId) -> Option { + let meta = self.0.iter().find(|meta| meta.id == *id)?; + let content = std::fs::read_to_string(Self::document_path(id)).ok()?; + Some(Document { + content, + name: meta.name.clone(), + path: meta.path.clone(), + is_saved: meta.is_saved, + }) + } + + fn force_order(&mut self, desired_order: Vec) { + let mut ordered_prefix_len = 0; + for id in desired_order { + if let Some(offset) = self.0[ordered_prefix_len..].iter().position(|meta| meta.id == id) { + let found_index = ordered_prefix_len + offset; + if found_index != ordered_prefix_len { + self.0[ordered_prefix_len..=found_index].rotate_right(1); + } + ordered_prefix_len += 1; + } + } + self.0.truncate(ordered_prefix_len); + } + + fn document_ids(&self) -> Vec { + self.0.iter().map(|meta| meta.id).collect() + } + + fn document_path(id: &DocumentId) -> std::path::PathBuf { + let mut path = crate::dirs::graphite_autosave_documents_dir(); + path.push(format!("{:x}.graphite", id.0)); + path + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct DocumentInfo { + id: DocumentId, + name: String, + path: Option, + is_saved: bool, +} +impl DocumentInfo { + fn new(id: DocumentId, Document { name, path, is_saved, .. }: &Document) -> Self { + Self { + id, + name: name.clone(), + path: path.clone(), + is_saved: *is_saved, + } + } +} diff --git a/desktop/wrapper/Cargo.toml b/desktop/wrapper/Cargo.toml index 5e613375..53302baa 100644 --- a/desktop/wrapper/Cargo.toml +++ b/desktop/wrapper/Cargo.toml @@ -30,3 +30,4 @@ dirs = { workspace = true } ron = { workspace = true} vello = { workspace = true } image = { workspace = true } +serde = { workspace = true } diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 250c9201..66ad7cd7 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -118,5 +118,27 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess let message = AppWindowMessage::AppWindowUpdatePlatform { platform }; dispatcher.queue_editor_message(message.into()); } + DesktopWrapperMessage::LoadDocument { + id, + document, + to_front, + select_after_open, + } => { + let message = PortfolioMessage::OpenDocumentFileWithId { + document_id: id, + document_name: Some(document.name), + document_path: document.path, + document_serialized_content: document.content, + document_is_auto_saved: true, + document_is_saved: document.is_saved, + to_front, + select_after_open, + }; + dispatcher.queue_editor_message(message.into()); + } + DesktopWrapperMessage::SelectDocument { id } => { + let message = PortfolioMessage::SelectDocument { document_id: id }; + dispatcher.queue_editor_message(message.into()); + } } } diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index ed52205f..26a6a4f1 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use graphite_editor::messages::prelude::FrontendMessage; use super::DesktopWrapperMessageDispatcher; -use super::messages::{DesktopFrontendMessage, FileFilter, OpenFileDialogContext, SaveFileDialogContext}; +use super::messages::{DesktopFrontendMessage, Document, FileFilter, OpenFileDialogContext, SaveFileDialogContext}; pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageDispatcher, message: FrontendMessage) -> Option { match message { @@ -67,12 +67,46 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::UpdateWindowState { maximized, minimized } => { dispatcher.respond(DesktopFrontendMessage::UpdateWindowState { maximized, minimized }); - // Forward this to update the ui + // Forward this to update the UI return Some(message); } FrontendMessage::CloseWindow => { dispatcher.respond(DesktopFrontendMessage::CloseWindow); } + FrontendMessage::TriggerPersistenceWriteDocument { document_id, document, details } => { + dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument { + id: document_id, + document: Document { + name: details.name, + path: None, + content: document, + is_saved: details.is_saved, + }, + }); + } + FrontendMessage::TriggerPersistenceRemoveDocument { document_id } => { + dispatcher.respond(DesktopFrontendMessage::PersistenceDeleteDocument { id: document_id }); + } + FrontendMessage::UpdateActiveDocument { document_id } => { + dispatcher.respond(DesktopFrontendMessage::PersistenceUpdateCurrentDocument { id: document_id }); + + // Forward this to update the UI + return Some(FrontendMessage::UpdateActiveDocument { document_id }); + } + FrontendMessage::UpdateOpenDocumentsList { open_documents } => { + dispatcher.respond(DesktopFrontendMessage::PersistenceUpdateDocumentsList { + ids: open_documents.iter().map(|document| document.id).collect(), + }); + + // Forward this to update the UI + return Some(FrontendMessage::UpdateOpenDocumentsList { open_documents }); + } + FrontendMessage::TriggerLoadFirstAutoSaveDocument => { + dispatcher.respond(DesktopFrontendMessage::PersistenceLoadCurrentDocument); + } + FrontendMessage::TriggerLoadRestAutoSaveDocuments => { + dispatcher.respond(DesktopFrontendMessage::PersistenceLoadRemainingDocuments); + } m => return Some(m), } None diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index dae7aaaa..b4db4613 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -1,8 +1,7 @@ -use std::path::PathBuf; - -use graphite_editor::messages::prelude::{DocumentId, FrontendMessage}; - +pub use graphite_editor::messages::prelude::DocumentId; +use graphite_editor::messages::prelude::FrontendMessage; pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; +use std::path::PathBuf; pub enum DesktopFrontendMessage { ToWeb(Vec), @@ -34,27 +33,81 @@ pub enum DesktopFrontendMessage { maximized: bool, minimized: bool, }, + PersistenceWriteDocument { + id: DocumentId, + document: Document, + }, + PersistenceDeleteDocument { + id: DocumentId, + }, + PersistenceUpdateCurrentDocument { + id: DocumentId, + }, + PersistenceLoadCurrentDocument, + PersistenceLoadRemainingDocuments, + PersistenceUpdateDocumentsList { + ids: Vec, + }, CloseWindow, } +pub enum DesktopWrapperMessage { + FromWeb(Box), + OpenFileDialogResult { + path: PathBuf, + content: Vec, + context: OpenFileDialogContext, + }, + SaveFileDialogResult { + path: PathBuf, + context: SaveFileDialogContext, + }, + OpenDocument { + path: PathBuf, + content: Vec, + }, + OpenFile { + path: PathBuf, + content: Vec, + }, + ImportFile { + path: PathBuf, + content: Vec, + }, + ImportSvg { + path: PathBuf, + content: Vec, + }, + ImportImage { + path: PathBuf, + content: Vec, + }, + PollNodeGraphEvaluation, + UpdatePlatform(Platform), + LoadDocument { + id: DocumentId, + document: Document, + to_front: bool, + select_after_open: bool, + }, + SelectDocument { + id: DocumentId, + }, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct Document { + pub content: String, + pub name: String, + pub path: Option, + pub is_saved: bool, +} + pub struct FileFilter { pub name: String, pub extensions: Vec, } -pub enum DesktopWrapperMessage { - FromWeb(Box), - OpenFileDialogResult { path: PathBuf, content: Vec, context: OpenFileDialogContext }, - SaveFileDialogResult { path: PathBuf, context: SaveFileDialogContext }, - OpenDocument { path: PathBuf, content: Vec }, - OpenFile { path: PathBuf, content: Vec }, - ImportFile { path: PathBuf, content: Vec }, - ImportSvg { path: PathBuf, content: Vec }, - ImportImage { path: PathBuf, content: Vec }, - PollNodeGraphEvaluation, - UpdatePlatform(Platform), -} - pub enum OpenFileDialogContext { Document, Import,