mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
Merge pull request #390 from woodgrain816/open-recent
File › Open Recent: the last 10 sessions, newest first
This commit is contained in:
4 files changed
+179
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user