diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 011f09e..1d23325 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -67,6 +67,10 @@ impl eframe::App for App { self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into(); } self.0.ui.ui_scale = self.0.extra.ui_scale; + // Saved now rather than at exit: the recent list matters most after a crash. + if self.0.take_recent_changed() { + save_prefs(&self.0.ui); + } // With the system menu bar there is no egui bar showing the window and dirty state, so the // title carries them; elsewhere the egui menu bar already does. let title = if self.0.native_menu_bar { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92da5d1..fdb0d16 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod mix_window; pub mod ops_windows; pub mod palette; pub mod panels; +mod recent; pub mod recovery; pub mod score_editor; pub mod shortcuts; @@ -53,6 +54,8 @@ pub enum MainWindow { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct UiState { + /// File › Open Recent, newest first (see `recent`). + pub recent_sessions: Vec, pub docking: docking::Workspace, #[serde(default = "docking::mix_default")] pub mix_docking: docking::Workspace, @@ -121,6 +124,7 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + recent_sessions: Vec::new(), docking: docking::Workspace::default(), mix_docking: docking::mix_default(), window: MainWindow::Edit, @@ -234,6 +238,12 @@ pub enum Gesture { pub struct SoundApp { pub engine: Engine, + /// The session path as of the last frame, and whether the recent list changed since the host + /// last saved it (see `recent`). + recent_seen: Option, + recent_changed: bool, + /// When the recent sessions were last checked for existence, which list, and the result. + recent_checked: Option<(f64, Vec, Vec)>, pub player: Option, /// Input capture, opened on first record. pub recorder: Option, @@ -322,6 +332,9 @@ fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool { impl SoundApp { pub fn new(engine: Engine, player: Option, services: Services) -> Self { SoundApp { + recent_seen: engine.path.clone(), + recent_changed: false, + recent_checked: None, engine, player, recorder: None, @@ -1073,6 +1086,7 @@ impl SoundApp { let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx); + self.note_recent_session(); self.apply_editor_edits(); bounce::step(self, ctx); if !ctx.input(|i| i.pointer.any_down()) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 75c3f35..c62ffce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -238,6 +238,9 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { ui.menu_button(&root.label, |ui| { for c in &root.children { menu_node(app, ui, c, &extra); + if c.path == "File > Open" { + crate::recent::menu(app, ui); + } } if root.label == "AudioSuite" { ui.separator(); diff --git a/crates/ui-egui/src/recent.rs b/crates/ui-egui/src/recent.rs new file mode 100644 index 0000000..12be398 --- /dev/null +++ b/crates/ui-egui/src/recent.rs @@ -0,0 +1,158 @@ +//! File › Open Recent: the sessions last opened or saved, newest first. +//! +//! The list lives in [`crate::UiState::recent_sessions`], so it is saved with the rest of the UI +//! state in `ui.json`. A desktop host saves that only on a clean exit, and a list kept only until +//! then is lost by exactly the crash that sends someone looking for their last session, so a +//! change raises [`crate::SoundApp::recent_changed`] for the host to save it straight away. +//! +//! Entries are recorded from the engine's session path whenever it changes (open, save as), not +//! from the commands themselves, so every way a session gets a path (menu, control channel, MCP, +//! a file dropped on the window) is covered by one rule. A file that has gone missing is shown +//! greyed out instead of failing on click; whether each one exists is checked at most every few +//! seconds while the menu is open, because a session on a network share costs a round trip. + +use crate::SoundApp; +use serde_json::json; +use std::path::Path; + +/// How many sessions the menu keeps. +pub const MAX_RECENT: usize = 10; +/// Seconds an existence check stays fresh while the menu is open. +const EXISTS_TTL: f64 = 3.0; + +/// The same file? On Windows paths are case-insensitive and take either separator. +fn same(a: &str, b: &str) -> bool { + if cfg!(windows) { + let norm = |s: &str| s.replace('/', "\\").to_lowercase(); + norm(a) == norm(b) + } else { + a == b + } +} + +/// Moves `path` to the front of `list` (adding it if new) and keeps at most [`MAX_RECENT`]. +/// Returns whether the list changed. +pub fn note(list: &mut Vec, path: &str) -> bool { + if path.is_empty() || list.first().is_some_and(|p| p == path) { + return false; + } + list.retain(|p| !same(p, path)); + list.insert(0, path.to_string()); + list.truncate(MAX_RECENT); + true +} + +/// "name — folder" for a session path. +fn label(path: &str) -> String { + let p = Path::new(path); + let name = p.file_stem().and_then(|n| n.to_str()).unwrap_or(path); + match p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()) { + Some(folder) => format!("{name} — {folder}"), + None => name.to_string(), + } +} + +impl SoundApp { + /// Records the session path if it changed since the last frame (see the module docs). + pub(crate) fn note_recent_session(&mut self) { + if self.engine.path == self.recent_seen { + return; + } + self.recent_seen.clone_from(&self.engine.path); + if let Some(p) = self.recent_seen.clone() + && note(&mut self.ui.recent_sessions, &p) + { + self.recent_changed = true; + } + } + + /// For the desktop host: whether the recent list changed since the last call (save it now). + pub fn take_recent_changed(&mut self) -> bool { + std::mem::take(&mut self.recent_changed) + } + + /// Whether each recent session still exists, re-checked at most every [`EXISTS_TTL`] seconds. + fn recent_exists(&mut self, now: f64) -> Vec { + let stale = self.recent_checked.as_ref().is_none_or(|(at, list, _)| now - at > EXISTS_TTL || *list != self.ui.recent_sessions); + if stale { + let exists = self.ui.recent_sessions.iter().map(|p| Path::new(p).is_file()).collect(); + self.recent_checked = Some((now, self.ui.recent_sessions.clone(), exists)); + } + self.recent_checked.as_ref().map(|(_, _, e)| e.clone()).unwrap_or_default() + } +} + +/// The File › Open Recent submenu. +pub fn menu(app: &mut SoundApp, ui: &mut egui::Ui) { + ui.menu_button("Open Recent", |ui| { + if app.ui.recent_sessions.is_empty() { + ui.add_enabled(false, egui::Button::new("No Recent Sessions")); + return; + } + let now = ui.ctx().input(|i| i.time); + let exists = app.recent_exists(now); + let mut open = None; + for (i, path) in app.ui.recent_sessions.iter().enumerate() { + let here = exists.get(i).copied().unwrap_or(true); + let text = if here { label(path) } else { format!("{} (missing)", label(path)) }; + let r = ui.add_enabled(here, egui::Button::new(text)).on_hover_text(path.as_str()).on_disabled_hover_text(format!("{path}\nnot found")); + if r.clicked() { + open = Some(path.clone()); + } + } + ui.separator(); + if ui.button("Clear Recent").clicked() { + app.ui.recent_sessions.clear(); + app.recent_changed = true; + ui.close(); + } + if let Some(path) = open { + ui.close(); + // Through the dialog layer, so unsaved changes are asked about first. + if let Err(e) = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path})) { + log::warn!("{path}: {e}"); + app.ui.status = format!("Could not open {path}: {e}"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newest_first_without_duplicates_and_capped() { + let mut l = Vec::new(); + assert!(note(&mut l, "a.scraft")); + assert!(note(&mut l, "b.scraft")); + assert!(!note(&mut l, "b.scraft"), "already at the front"); + assert!(note(&mut l, "a.scraft")); + assert_eq!(l, ["a.scraft", "b.scraft"]); + for i in 0..20 { + note(&mut l, &format!("{i}.scraft")); + } + assert_eq!(l.len(), MAX_RECENT); + assert_eq!(l.first().map(String::as_str), Some("19.scraft")); + assert!(!note(&mut l, ""), "an empty path is never recorded"); + } + + #[test] + fn windows_paths_match_regardless_of_case_and_separator() { + let mut l = vec![r"C:\Songs\Mix.scraft".to_string()]; + note(&mut l, "c:/songs/mix.scraft"); + assert_eq!(l.len(), if cfg!(windows) { 1 } else { 2 }); + } + + #[test] + fn label_names_the_session_and_its_folder() { + assert_eq!(label("/x/on the mob/on the mob.scraft"), "on the mob — on the mob"); + assert_eq!(label("solo.scraft"), "solo"); + } + + #[test] + fn older_ui_json_without_the_list_still_loads() { + let ui: crate::UiState = serde_json::from_str("{}").unwrap(); + assert!(ui.recent_sessions.is_empty()); + } +}