diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0c..1ca35f26 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -295,6 +295,17 @@ pub struct EffectcraftApp { pub(crate) template_list: Option<(Vec, Vec)>, } +/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]). +pub const WORKSPACES_FILE: &str = "workspaces.json"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct SavedWorkspaces { + #[serde(default)] + workspaces: std::collections::BTreeMap, + #[serde(default)] + floating: std::collections::BTreeMap>, +} + impl EffectcraftApp { pub fn new(session: Session) -> Self { EffectcraftApp { @@ -353,6 +364,34 @@ impl EffectcraftApp { template_list: None, } .with_ui_commands() + .with_saved_workspaces() + } + + /// The workspaces saved in an earlier session (Window ▸ Workspace ▸ Save as New Workspace, + /// Save Changes to this Workspace), from the settings store. A file that doesn't read is + /// ignored. + fn with_saved_workspaces(mut self) -> Self { + let text = self.session.config.as_ref().and_then(|c| c.read(WORKSPACES_FILE)); + match text.map(|t| serde_json::from_str::(&t)) { + Some(Ok(w)) => { + self.ui.saved_workspaces = w.workspaces; + self.ui.saved_floating = w.floating; + } + Some(Err(e)) => log::warn!("{WORKSPACES_FILE}: {e}"), + None => {} + } + self + } + + /// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After + /// Effects' saved workspaces last until they are deleted (#320). + pub(crate) fn store_saved_workspaces(&self) { + let Some(c) = &self.session.config else { return }; + let w = SavedWorkspaces { workspaces: self.ui.saved_workspaces.clone(), floating: self.ui.saved_floating.clone() }; + let written = serde_json::to_string(&w).map_err(std::io::Error::other).and_then(|t| c.write(WORKSPACES_FILE, &t)); + if let Err(e) = written { + log::warn!("{WORKSPACES_FILE}: {e}"); + } } /// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779f..6b21c0c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -943,6 +943,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.saveWorkspaceAs" => { @@ -959,6 +960,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); app.ui.workspace = name.clone(); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.editWorkspaces" => { @@ -983,6 +985,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } + app.store_saved_workspaces(); if app.ui.workspace == name { app.set_workspace("Default"); } @@ -1000,6 +1003,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.workspace == name { app.ui.workspace = new.to_string(); } + app.store_saved_workspaces(); return Ok(json!({"renamed": new})); } json!({"workspaces": app.workspace_names()}) diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146..920e4948 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -117,6 +117,42 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(3); } +/// Issue #320: saved workspaces last between sessions (they were only kept in memory): a new +/// app on the same settings store lists them and brings their layout back; renamed and deleted +/// ones stay that way. +#[test] +fn saved_workspaces_persist_across_sessions() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + use effectcraft_ui_egui::menus::invoke; + let store = std::sync::Arc::new(MemoryConfig::default()); + let launch = || { + let s = Session { config: Some(store.clone()), ..Default::default() }; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(2); + h + }; + let mut h = launch(); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap(); + let saved = h.state().ui.dock.clone(); + drop(h); + let mut h = launch(); + assert_eq!(h.state().saved_workspace_names(), ["My Layout", "Scratch"]); + assert_eq!(h.state().ui.workspace, "Default"); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "My Layout"})).unwrap(); + assert_eq!(h.state().ui.dock, saved); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).unwrap(); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "My Layout", "rename": "Edit Bay"})).unwrap(); + drop(h); + assert_eq!(launch().state().saved_workspace_names(), ["Edit Bay"]); + // A damaged file is ignored. + store.write(effectcraft_ui_egui::WORKSPACES_FILE, "{not json").unwrap(); + assert!(launch().state().saved_workspace_names().is_empty()); +} + /// Issue #191: a workspace saved with Save as New Workspace is listed in Window ▸ Workspace /// (below the built-ins, where the submenu used to be cut off) and choosing it there brings its /// layout back.