Merge pull request #390 from woodgrain816/open-recent

File › Open Recent: the last 10 sessions, newest first
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 06:31:20 -04:00
commit 9176296e96
4 files changed
+179

No files matched your search

+4
View File
@@ -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 {
+14
View File
@@ -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<String>,
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<String>,
recent_changed: bool,
/// When the recent sessions were last checked for existence, which list, and the result.
recent_checked: Option<(f64, Vec<String>, Vec<bool>)>,
pub player: Option<Player>,
/// Input capture, opened on first record.
pub recorder: Option<soundcraft_playback::record::Recorder>,
@@ -322,6 +332,9 @@ fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool {
impl SoundApp {
pub fn new(engine: Engine, player: Option<Player>, 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()) {
+3
View File
@@ -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();
+158
View File
@@ -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<String>, 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<bool> {
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());
}
}