diff --git a/Cargo.lock b/Cargo.lock index 480e113..1adc92d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2142,6 +2142,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3513,6 +3525,7 @@ dependencies = [ "soundcraft-video", "soundcraft-vst3-host", "thiserror 2.0.21", + "zip", ] [[package]] @@ -3635,8 +3648,12 @@ version = "0.5.0" dependencies = [ "eframe", "egui", + "js-sys", "log", + "rfd", + "serde_json", "soundcraft-engine", + "soundcraft-model", "soundcraft-playback", "soundcraft-ui-egui", "wasm-bindgen", @@ -4096,6 +4113,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5386,6 +5409,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "nix", + "typed-path", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 4eaad85..f3c2aad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ proptest = "1" # audio symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } rustfft = "6" +# Session bundles (one file holding a session and its audio, for the browser): stored entries only. +zip = { version = "9", default-features = false } cpal = "0.16" # ui diff --git a/README.md b/README.md index cc1ffad..1d5f86a 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ where things are. Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes; - clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything. + clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that + follows your fingers and pinch-to-zoom around the pointer; undo for everything. - **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders, VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip effects, and automatic plugin delay compensation. diff --git a/apps/soundcraft-web/Cargo.toml b/apps/soundcraft-web/Cargo.toml index 64cdb1a..50ff3aa 100644 --- a/apps/soundcraft-web/Cargo.toml +++ b/apps/soundcraft-web/Cargo.toml @@ -17,10 +17,14 @@ path = "src/main.rs" soundcraft-engine = { workspace = true } soundcraft-ui-egui = { workspace = true } soundcraft-playback = { workspace = true } +soundcraft-model = { workspace = true } egui = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } +rfd = { workspace = true } +js-sys = "0.3" # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] } diff --git a/apps/soundcraft-web/src/main.rs b/apps/soundcraft-web/src/main.rs index cb089a5..6e52278 100644 --- a/apps/soundcraft-web/src/main.rs +++ b/apps/soundcraft-web/src/main.rs @@ -7,7 +7,10 @@ //! //! Differences from the desktop app: //! - no TCP control channel (browsers can't listen on sockets); -//! - no native file dialogs: the demo session is loaded on start; +//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are +//! read as bytes through the browser's file picker; a session is saved as ONE bundle file +//! (`.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a +//! download (the demo session is still loaded on start); //! - browsers only start audio after a user gesture, so the first click may be needed before //! playback is heard. //! diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 78acba4..02f1e13 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -1,7 +1,11 @@ //! The browser shell: the eframe web runner around [`SoundApp`]. -use soundcraft_engine::Engine; +use serde_json::json; +use soundcraft_engine::{Engine, io}; +use soundcraft_ui_egui::dialogs::Dialog; use soundcraft_ui_egui::{Services, SoundApp}; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::Arc; use wasm_bindgen::JsCast as _; @@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading"; pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + io::set_deliver(download); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { log::error!("no document"); @@ -36,7 +41,7 @@ pub fn start() { let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() }; let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone())); let app = SoundApp::new(engine, Some(player), Services::default()); - Ok(Box::new(WebShell(app))) + Ok(Box::new(WebShell { app, inbox: Rc::default() })) }), ) .await; @@ -53,18 +58,182 @@ fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } -struct WebShell(SoundApp); +/// What a file read from the browser is for. +#[derive(Debug, Clone, Copy)] +enum Purpose { + Session, + Audio, + Midi, +} + +const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; +const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"]; +const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; + +impl Purpose { + /// A dropped file goes where its extension says: sessions open, MIDI and audio import. + fn of(name: &str) -> Self { + let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default(); + if SESSION_EXTS.contains(&ext.as_str()) { + Self::Session + } else if MIDI_EXTS.contains(&ext.as_str()) { + Self::Midi + } else { + Self::Audio + } + } + + /// The command whose path prompt this replaces in the browser. + fn for_command(cmd: &str) -> Option { + match cmd { + "session.open" => Some(Self::Session), + "file.import_audio" => Some(Self::Audio), + "file.import_midi" => Some(Self::Midi), + _ => None, + } + } + + fn filter(self) -> (&'static str, &'static [&'static str]) { + match self { + Self::Session => ("SoundCraft Session", SESSION_EXTS), + Self::Audio => ("Audio", AUDIO_EXTS), + Self::Midi => ("MIDI", MIDI_EXTS), + } + } +} + +/// Files picked or dropped, read asynchronously (browsers can only read files that way) and +/// handled on the next frame. The browser has no paths: files travel as bytes. +type Inbox = Rc)>>>; + +struct WebShell { + app: SoundApp, + inbox: Inbox, +} + +impl WebShell { + /// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the + /// browser can't read (#63, #64). Show the browser's file picker instead. + fn take_path_prompt(&mut self, ctx: &egui::Context) { + let purpose = match &self.app.dialogs.open { + Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd), + _ => None, + }; + let Some(purpose) = purpose else { return }; + self.app.dialogs.open = None; + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let (label, exts) = purpose.filter(); + let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return }; + let bytes = file.read().await; + inbox.borrow_mut().push((purpose, file.file_name(), bytes)); + ctx.request_repaint(); + }); + } + + fn take_drops(&mut self, ctx: &egui::Context) { + // In the browser a dropped file's `path()` is only its name: read its bytes instead. + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into()); + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + match f.bytes_async().await { + Ok(bytes) => { + inbox.borrow_mut().push((Purpose::of(&name), name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } + + fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) { + let e = &mut self.app.engine; + let at = e.session().edit.selection.start.max(0); + let result = match purpose { + Purpose::Session => { + let _ = self.app.run("transport.stop", json!({})); + io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| { + if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) } + }) + } + // Imports are edits: one undo step each, like the import commands. + Purpose::Audio => { + let before = e.session_arc(); + io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| { + e.push_undo("Import Audio", before); + format!("Imported {name}") + }) + } + Purpose::Midi => { + let before = e.session_arc(); + io::import_midi_bytes(e, bytes, at, true).map(|_| { + e.push_undo("Import MIDI", before); + format!("Imported {name}") + }) + } + }; + self.app.ui.status = result.unwrap_or_else(|err| err.to_string()); + } +} impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.0.logic(ctx); + self.take_drops(ctx); + self.take_path_prompt(ctx); + let pending = std::mem::take(&mut *self.inbox.borrow_mut()); + for (purpose, name, bytes) in pending { + self.handle(purpose, &name, &bytes); + } + self.app.logic(ctx); + // A prompt opened during this frame becomes a picker on the next one. + self.take_path_prompt(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.app.ui(ui); } } + +/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery +/// hook on the web). +fn download(name: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}"); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() { + Some("scraftz") => "application/zip", + Some("wav") => "audio/wav", + Some("aif" | "aiff") => "audio/aiff", + Some("flac") => "audio/flac", + Some("mid" | "midi") => "audio/midi", + Some("svg") => "image/svg+xml", + Some("txt") => "text/plain", + Some("json" | "scraft") => "application/json", + Some("musicxml") => "application/vnd.recordare.musicxml+xml", + _ => "application/octet-stream", + }); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; + a.set_href(&url); + a.set_download(name); + a.style().set_property("display", "none").map_err(js)?; + document.body().ok_or("no body")?.append_child(&a).map_err(js)?; + a.click(); + a.remove(); + // Revoke after the click was dispatched; the download keeps its own reference. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; + Ok(()) +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 61f20bd..28723b7 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -20,11 +20,20 @@ mod logging; mod native_menu; use soundcraft_engine::Engine; -use soundcraft_ui_egui::{Services, SoundApp, UiState}; +use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs}; use std::sync::Arc; struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option); +/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back. +fn set_title(ctx: &egui::Context, title: String) { + let id = egui::Id::new("window_title"); + if ctx.data(|d| d.get_temp::(id)).as_deref() != Some(title.as_str()) { + ctx.data_mut(|d| d.insert_temp(id, title.clone())); + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + } +} + impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // What the realtime audio thread logged is written here, off that thread. @@ -48,6 +57,8 @@ impl eframe::App for App { } if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } soundcraft_ui_egui::fit_window_to_screen(ctx); let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false))); @@ -68,7 +79,7 @@ impl eframe::App for App { } else { format!("{} — SoundCraft", self.0.engine.session().name) }; - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + set_title(ctx, title); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); @@ -91,7 +102,7 @@ impl eframe::App for App { fn open_path(app: &mut SoundApp, p: &str) { let lower = p.to_ascii_lowercase(); let r = if lower.ends_with(".scraft") { - app.run("session.open", serde_json::json!({"path": p})) + dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p})) } else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") { app.run("file.import_midi", serde_json::json!({"path": p})) } else { @@ -283,3 +294,21 @@ fn main() -> eframe::Result { fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] { if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] } } + +#[cfg(test)] +mod tests { + use super::set_title; + + #[test] + fn the_title_is_sent_only_when_it_changes() { + let ctx = egui::Context::default(); + let titles = |title: &str| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string())); + out.textures_delta.clear(); + out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count() + }; + assert_eq!(titles("Song — SoundCraft"), 1); + assert_eq!(titles("Song — SoundCraft"), 0); + assert_eq!(titles("Song * — SoundCraft"), 1); + } +} diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index 4bd085a..e0dc93e 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -113,6 +113,11 @@ impl NativeMenu { self.last_sync = Some(now); for entry in &mut self.entries { let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref()); + if let Some(label) = menus::live_label(app, entry.command.as_deref()) + && entry.item.text() != label + { + entry.item.set_text(label); + } if enabled != entry.enabled { entry.item.set_enabled(enabled); entry.enabled = enabled; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b698856..9b097a0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true log.workspace = true +zip.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dd49ed4..776fe02 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -356,7 +356,8 @@ fn select(e: &mut Engine, p: &Value) -> Result { p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::>()); let list_changes = clips.is_some() || sources.is_some() || st.is_some(); let exact = bool_or(p, "exact", false); - let s = e.session_mut(); + let s = e.session(); + let mut ed = s.edit.clone(); if let Some(mut t) = tracks { // Active edit groups select all their members together. if !exact { @@ -370,11 +371,11 @@ fn select(e: &mut Engine, p: &Value) -> Result { let order: Vec = s.tracks.iter().map(|x| x.id).collect(); t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); } - s.edit.selected_tracks = t; + ed.selected_tracks = t; } match (st, en) { - (Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), - (Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)), + (Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), + (Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)), _ => {} } if let Some(c) = clips { @@ -390,21 +391,25 @@ fn select(e: &mut Engine, p: &Value) -> Result { } } if let Some(r) = range { - s.edit.selection = r; - s.edit.selected_tracks = trs; + ed.selection = r; + ed.selected_tracks = trs; } - s.edit.selected_clips = c; + ed.selected_clips = c; } else if list_changes { - s.edit.selected_clips.clear(); + ed.selected_clips.clear(); } if list_changes { let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect(); - s.edit.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect(); + ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect(); } - if s.edit.link_timeline_edit { - s.edit.timeline_selection = s.edit.selection; + if ed.link_timeline_edit { + ed.timeline_selection = ed.selection; } - Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips, "sources": s.edit.selected_sources})) + let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources}); + if ed != s.edit { + e.session_mut().edit = ed; + } + Ok(out) } fn shift_selection(e: &mut Engine, dir: i64) -> Result { @@ -760,3 +765,21 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { } Ok(json!({"consolidated": n})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selecting_what_is_already_selected_leaves_the_document_alone() { + let mut e = Engine::default(); + let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200}); + e.execute("edit.select", &sel).unwrap(); + let rev = e.revision; + assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]])); + assert_eq!(e.revision, rev); + e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); + assert_ne!(e.revision, rev); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 805acb0..ddbabf8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,10 +42,14 @@ pub fn specs() -> Vec { s.sources.clear(); s.videos.clear(); let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?; + if cfg!(target_arch = "wasm32") { + crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?; + } else { + crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?; + } Ok(json!({"path": file})) }), - cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { + cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| { let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?; crate::io::open_session(e, &path)?; Ok(json!({})) @@ -103,13 +107,13 @@ pub fn specs() -> Vec { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None }; let bytes = crate::io::export_midi(e, only.as_deref())?; - std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| { let text = crate::inspect::session_text(e); if let Some(path) = str_param(p, "path") { - std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(path, text.as_bytes())?; } Ok(json!({"text": text})) }), @@ -289,7 +293,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let fold = fold_down_param(p, "file.bounce_mix")?; let bus = bounce_bus(e, p)?; let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?; - std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, &bytes)?; Ok( json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), ) @@ -308,7 +312,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result Some(p) => p.to_string(), None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(), }; - std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0); Ok(json!({"path": path, "parts": parts.len(), "measures": measures})) } @@ -466,6 +470,17 @@ mod tests { assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn saving_a_session_without_audio_creates_its_folder() { + let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song").join("Song.scraft"); + Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(&dir); + } + fn tone_engine() -> (Engine, u64) { let mut e = Engine::default(); e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index d77e12b..1b9b56d 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -105,7 +105,15 @@ pub fn specs() -> Vec { insert_params ), cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move), - cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", has_selection, send), + cmd!( + "mix.send", + "Assign Send", + [], + None, + "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", + has_selection, + send + ), cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| { let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?; if let Some(tr) = e.session_mut().track_mut(t) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2ffc59f..d0a453c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -53,6 +53,8 @@ pub struct CommandSpec { pub run: Run, pub journal: bool, pub undoable: bool, + /// Only touches `edit.zoom`, through `Engine::zoom_mut`. + pub view: bool, } /// Serializable description of a command. @@ -82,18 +84,16 @@ impl CommandSpec { } } -/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants. +/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants. #[macro_export] macro_rules! cmd { - (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false } - }; - (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false } - }; - ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true } + (query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) }; + (noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) }; + (view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) }; + (@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view } }; + ($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) }; } pub fn command_specs() -> &'static [CommandSpec] { diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs index be533aa..b3ca0d3 100644 --- a/crates/engine/src/cmd/plugin_state.rs +++ b/crates/engine/src/cmd/plugin_state.rs @@ -17,6 +17,7 @@ pub fn specs() -> Vec { run: insert_state, journal: false, undoable: false, + view: false, }] } diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index e3d202e..8818342 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result { "comments": tr.comments, }); let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; Ok(json!({"path": path, "track": first})) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9f24e3c..7b36430 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat}; pub fn specs() -> Vec { vec![ - cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)), - cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)), - cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { - let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0); - e.session_mut().edit.zoom.samples_per_px = v; + cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)), + cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)), + cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| { + let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?; + zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64)) + }), + cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { + let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP); + e.zoom_mut().samples_per_px = v; Ok(json!({"samples_per_px": v})) }), - cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { + cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize; - let s = e.session_mut(); + let z = e.zoom_mut(); if bool_or(p, "store", false) { - if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px } - } else if let Some(z) = s.edit.zoom.presets.get(i) { - s.edit.zoom.samples_per_px = *z; + if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px } + } else if let Some(v) = z.presets.get(i) { + z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP); } - Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px})) + Ok(json!({"samples_per_px": z.samples_per_px})) }), - cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { + cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { let w = f64_or(p, "width_px", 1200.0).max(100.0); - let s = e.session_mut(); - let r = s.edit.selection; + let r = e.session().edit.selection; if !r.is_empty() { - s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25); - s.edit.zoom.scroll = r.start; + let z = e.zoom_mut(); + z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP); + z.scroll = r.start; } Ok(json!({})) }), - cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { + cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { let w = f64_or(p, "width_px", 1200.0).max(100.0); - let s = e.session_mut(); + let s = e.session(); let end = s.content_end().max(s.sample_rate.samples(30.0)); - s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25); - s.edit.zoom.scroll = 0; + let z = e.zoom_mut(); + z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP); + z.scroll = 0; Ok(json!({})) }), - cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { + cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { let to = position_param(e, "view.scroll", p, "to")?; let by = f64_or(p, "by_px", 0.0); - let s = e.session_mut(); - let spp = s.edit.zoom.samples_per_px; - s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0); - Ok(json!({"scroll": s.edit.zoom.scroll})) + let z = e.zoom_mut(); + let by = soundcraft_time::to_samples(by * z.samples_per_px); + z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION); + Ok(json!({"scroll": z.scroll})) }), - cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { - let s = e.session_mut(); - let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) }; - s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 }; - Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom})) + cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { + let z = e.zoom_mut(); + let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) }; + z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 }; + Ok(json!({"waveform_zoom": z.waveform_zoom})) }), cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler), cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| { @@ -88,18 +93,19 @@ pub fn specs() -> Vec { ] } -fn zoom(e: &mut Engine, f: f64) -> Result { - let s = e.session_mut(); - let anchor = s.edit.selection.start; - let old = s.edit.zoom.samples_per_px; - let new = (old * f).clamp(0.25, 1_000_000.0); - // Keep the insertion point at the same screen x when it is visible. - let x = (anchor - s.edit.zoom.scroll) as f64 / old; - if (0.0..4000.0).contains(&x) { - s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0); +const MIN_SPP: f64 = 0.25; +const MAX_SPP: f64 = 1_000_000.0; + +fn zoom(e: &mut Engine, f: f64, x: Option) -> Result { + let insertion = e.session().edit.selection.start as f64; + let z = e.zoom_mut(); + let (old, scroll) = (z.samples_per_px, z.scroll as f64); + let new = (old * f).clamp(MIN_SPP, MAX_SPP); + if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) { + z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION); } - s.edit.zoom.samples_per_px = new; - Ok(json!({"samples_per_px": new})) + z.samples_per_px = new; + Ok(json!({"samples_per_px": new, "scroll": z.scroll})) } fn ruler(e: &mut Engine, p: &Value) -> Result { @@ -157,4 +163,55 @@ mod tests { assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5))); assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None); } + + fn view(e: &Engine) -> (f64, Samples) { + (e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll) + } + + #[test] + fn zoom_by_holds_the_position_under_the_pointer() { + let mut e = Engine::default(); + e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap(); + e.execute("view.scroll", &json!({"to": 48_000})).unwrap(); + e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap(); + assert_eq!(view(&e), (50.0, 78_000 - 300 * 50)); + e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap(); + assert_eq!(view(&e), (100.0, 48_000)); + } + + #[test] + fn zoom_and_scroll_stay_in_range() { + let mut e = Engine::default(); + e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap(); + e.execute("view.zoom_to_selection", &json!({})).unwrap(); + assert_eq!(view(&e).0, MAX_SPP); + e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap(); + assert_eq!(view(&e).0, MIN_SPP); + e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap(); + assert_eq!(view(&e), (MAX_SPP, 0)); + for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] { + assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}"); + } + e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap(); + assert_eq!(view(&e).1, MAX_POSITION); + e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap(); + assert_eq!(view(&e).1, 0); + } + + #[test] + fn view_commands_skip_undo_the_journal_and_session_copies() { + let mut e = Engine::default(); + let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone(); + let steps = e.undo_history().len(); + for db in [-1.0, -2.0] { + e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap(); + e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap(); + } + assert_eq!(e.undo_history().len(), steps + 1); + let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len()); + let before = state(&e); + e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap(); + e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap(); + assert_eq!(state(&e), before); + } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b5de447..191adb8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value { })).collect::>(), "selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}, "edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(), + "zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll}, "transport": e.transport, "undo": e.undo_label(), "redo": e.redo_label(), }) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index a13e1f8..108330e 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -510,7 +510,13 @@ pub fn sanitize_name(n: &str) -> String { /// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written /// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written. +/// +/// In the browser there are no folders: the session and all its audio go out as one bundle file +/// (`.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]). pub fn save_session(e: &mut Engine, path: &str) -> Result { + if cfg!(target_arch = "wasm32") { + return save_session_bundle(e, path); + } let (fresh, written, p) = write_session(e.session(), path, false)?; // Keep the in-memory document in sync (paths, unsaved flags) without an undo step. *e.session_mut() = fresh; @@ -709,16 +715,11 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { for src in s.sources.clone() { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; match std::fs::read(&p) { - Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) { - Ok((_, mut buf)) => { - if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { - buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); - buf.sample_rate = s.sample_rate.hz(); - } - s.pool.insert(src.id, Arc::new(SourceAudio::new(buf))); + Ok(bytes) => { + if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) { + missing.push(format!("{}: {err}", p.display())); } - Err(err) => missing.push(format!("{}: {err}", p.display())), - }, + } Err(_) => missing.push(p.display().to_string()), } } @@ -732,6 +733,173 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { Ok(missing) } +/// Decode one source's audio file into the session pool, at the session rate. +fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> { + let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; + if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); + buf.sample_rate = s.sample_rate.hz(); + } + s.pool.insert(id, Arc::new(SourceAudio::new(buf))); + Ok(()) +} + +// ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── + +/// Extension of a session bundle: one zip file holding the `.scraft` session and its +/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it +/// saves and opens sessions as bundles. +pub const BUNDLE_EXTENSION: &str = "scraftz"; + +/// Most entries and bytes a bundle may unpack to (bundles are hostile input). +const MAX_BUNDLE_ENTRIES: usize = 10_000; +const MAX_BUNDLE_BYTES: u64 = 1 << 30; + +/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app +/// installs one that starts a download. Native builds write files and never install it. +pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>; + +static DELIVER: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Install the delivery hook (once, at start-up of the web app). +pub fn set_deliver(h: DeliverHook) { + let _ = DELIVER.set(h); +} + +/// Write one output file: to `path` natively; in the browser, delivered under its file name. +/// Without this, every export on the web failed with «operation not supported on this platform». +pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + if cfg!(target_arch = "wasm32") { + let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; + let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); + return deliver(name, bytes).map_err(EngineError::Io); + } + std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}"))) +} + +/// The session as a bundle: `.scraft` plus every source's audio as 32-bit float WAV under +/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the +/// session with its source paths pointing into the bundle. +pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { + use std::io::Write as _; + let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); + let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}")); + let mut s = session.clone(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true); + let mut taken = std::collections::HashSet::new(); + for src in &mut s.sources { + let Some(audio) = s.pool.get(src.id) else { continue }; + let base = sanitize_name(&src.name); + let mut target = format!("Audio Files/{base}.wav"); + let mut k = 1; + while taken.contains(&target) && k <= 9_999 { + target = format!("Audio Files/{base}_{k:02}.wav"); + k += 1; + } + let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?; + w.start_file(target.as_str(), opts).map_err(zerr)?; + w.write_all(&bytes).map_err(ioerr)?; + taken.insert(target.clone()); + src.path = target; + src.unsaved = false; + src.format = FileFormat::Wav; + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?; + w.write_all(text.as_bytes()).map_err(ioerr)?; + let bytes = w.finish().map_err(zerr)?.into_inner(); + Ok((bytes, s)) +} + +/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). +fn save_session_bundle(e: &mut Engine, path: &str) -> Result { + let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); + let mut s = e.session().clone(); + if let Some(stem) = stem { + s.name = stem; + } + let (bytes, fresh) = session_bundle(&s)?; + let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name)); + write_file(&file, &bytes)?; + let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count(); + *e.session_mut() = fresh; + e.path = Some(file); + e.mark_clean(); + Ok(written) +} + +/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare +/// `.scraft`, whose audio is then reported missing. Returns the missing media, like +/// [`open_session`]. +pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result> { + let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") { + read_bundle(bytes)? + } else { + ( + String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?, + std::collections::HashMap::new(), + ) + }; + let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; + let mut missing = Vec::new(); + for src in s.sources.clone() { + match files.remove(&src.path) { + Some(audio) => { + let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string); + if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) { + missing.push(format!("{}: {err}", src.path)); + } + } + None => missing.push(src.path.clone()), + } + } + // Movies are referenced by path, never copied into a session: none can be found here. + missing.extend(s.videos.iter().map(|v| v.path.clone())); + let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string(); + e.replace_session(s); + e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem))); + for m in &missing { + e.message(format!("missing media: {m}")); + } + Ok(missing) +} + +/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path. +fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap>)> { + use std::io::Read as _; + let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?; + if z.len() > MAX_BUNDLE_ENTRIES { + return Err(EngineError::Io("bundle: too many files".into())); + } + let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64); + for i in 0..z.len() { + let mut f = z.by_index(i).map_err(zerr)?; + if f.is_dir() { + continue; + } + let rel = f.name().map_err(zerr)?.to_string(); + let size = f.size(); + total = total.saturating_add(size); + if total > MAX_BUNDLE_BYTES { + return Err(EngineError::Io("bundle: too large".into())); + } + let mut data = Vec::new(); + // The declared size is hostile too: never read past it. + f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?; + let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION); + if is_session && text.is_none() { + text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?); + } else { + files.insert(rel, data); + } + } + let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?; + Ok((text, files)) +} + /// The record-enabled audio tracks, each with the input channels it records (those its input /// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file /// `_.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved). @@ -1060,3 +1228,75 @@ mod stem_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod bundle_tests { + use super::*; + + #[test] + fn bundle_round_trips_session_and_audio() { + let mut e = crate::demo::demo_engine(); + e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap(); + let (bytes, fresh) = session_bundle(e.session()).unwrap(); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved)); + let mut r = Engine::default(); + let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap(); + assert!(missing.is_empty(), "{missing:?}"); + assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); + assert_eq!(r.session().tracks.len(), e.session().tracks.len()); + for src in &e.session().sources { + let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap()); + assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name); + } + assert!(!r.is_dirty()); + assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz")); + } + + #[test] + fn same_named_sources_get_distinct_files() { + let mut e = crate::demo::demo_engine(); + let name = e.session().sources[0].name.clone(); + let s = e.session_mut(); + let n = s.sources.len(); + s.sources[1].name = name; + let (bytes, fresh) = session_bundle(e.session()).unwrap(); + let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect(); + assert_eq!(paths.len(), n); + let mut r = Engine::default(); + assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty()); + } + + #[test] + fn bare_session_opens_with_its_audio_reported_missing() { + let e = crate::demo::demo_engine(); + let (_, fresh) = session_bundle(e.session()).unwrap(); + let text = fresh.to_json().unwrap(); + let mut r = Engine::default(); + let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap(); + assert_eq!(missing.len(), fresh.sources.len()); + assert_eq!(r.session().tracks.len(), fresh.tracks.len()); + } + + #[test] + fn hostile_bundles_are_errors_not_panics() { + let mut r = Engine::default(); + for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] { + assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err()); + } + // A valid zip without a session inside. + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap(); + let only_audio = w.finish().unwrap().into_inner(); + assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err()); + assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone"); + } + + #[test] + fn write_file_writes_to_disk_natively() { + let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id())); + write_file(&p.to_string_lossy(), b"hello").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"hello"); + let _ = std::fs::remove_file(&p); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 50d82e4..b607534 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,8 +16,9 @@ pub mod io; pub mod score; use serde_json::{Value, json}; -use soundcraft_model::{Clip, Session, TrackId}; +use soundcraft_model::{Clip, Session, TrackId, ZoomState}; use soundcraft_time::Samples; +use std::cell::Cell; use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; @@ -108,9 +109,13 @@ pub struct Engine { pub journal: Vec<(String, Value)>, /// Path of the session file, if saved. pub path: Option, - /// Revision counter; bumps on every document change (UI caches key on it). + /// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it). pub revision: u64, dirty: bool, + /// The document as last opened or saved, for `is_dirty`. + saved: Arc, + /// `is_dirty` as of a revision. + dirty_at: Cell<(u64, bool)>, /// Gesture key for undo coalescing (see `execute_merged`). merge_key: Option, /// Messages for the UI status line / agents. @@ -126,8 +131,10 @@ impl Default for Engine { impl Engine { pub fn new(session: Session) -> Self { install_panic_hook(); + let doc = Arc::new(session); Engine { - doc: Arc::new(session), + saved: Arc::clone(&doc), + doc, undo: Vec::new(), redo: Vec::new(), history_limit: 200, @@ -138,6 +145,7 @@ impl Engine { path: None, revision: 1, dirty: false, + dirty_at: Cell::new((0, false)), merge_key: None, messages: Vec::new(), } @@ -158,23 +166,42 @@ impl Engine { Arc::make_mut(&mut self.doc) } + /// Like `session_mut`, for `edit.zoom` only and without bumping `revision`. + pub fn zoom_mut(&mut self) -> &mut ZoomState { + self.dirty = true; + &mut Arc::make_mut(&mut self.doc).edit.zoom + } + /// Replace the whole document (open / new); clears history. pub fn replace_session(&mut self, s: Session) { self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() }; self.transport_requests.clear(); self.transport_requests.push(TransportRequest::ResetSession); self.doc = Arc::new(s); + self.saved = Arc::clone(&self.doc); self.undo.clear(); self.redo.clear(); self.revision = self.revision.wrapping_add(1); self.dirty = false; } + /// Whether the document changed since it was opened or saved. Selecting, locating and zooming + /// don't count, and undoing back to the saved state makes it clean again. pub fn is_dirty(&self) -> bool { - self.dirty + if !self.dirty { + return false; + } + let (rev, dirty) = self.dirty_at.get(); + if rev == self.revision { + return dirty; + } + let dirty = !same_document(&self.saved, &self.doc); + self.dirty_at.set((self.revision, dirty)); + dirty } pub fn mark_clean(&mut self) { self.dirty = false; + self.saved = Arc::clone(&self.doc); } pub fn can_undo(&self) -> bool { @@ -227,7 +254,6 @@ impl Engine { /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the /// document is restored. pub fn execute(&mut self, id: &str, params: &Value) -> Result { - self.merge_key = None; let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(reason) = (spec.enabled)(self) { // A call that names its targets (tracks, clips, a range) does not need a selection; @@ -237,6 +263,10 @@ impl Engine { return Err(EngineError::Disabled(id.to_string(), reason)); } } + if spec.view { + return self.execute_view(spec, params); + } + self.merge_key = None; let before = Arc::clone(&self.doc); let rev = self.revision; let dirty = self.dirty; @@ -244,12 +274,10 @@ impl Engine { let result = match result { Ok(r) => r, Err(p) => { - let msg = - p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); self.doc = before; self.revision = rev.wrapping_add(1); self.dirty = dirty; - return Err(EngineError::Internal(id.to_string(), msg)); + return Err(EngineError::Internal(id.to_string(), panic_message(&*p))); } }; match result { @@ -274,6 +302,16 @@ impl Engine { } } + fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result { + let zoom = self.doc.edit.zoom; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))) + .unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p)))); + if result.is_err() && self.doc.edit.zoom != zoom { + Arc::make_mut(&mut self.doc).edit.zoom = zoom; + } + result + } + /// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls /// with the same `key` collapse into a single undo step. pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result { @@ -318,6 +356,19 @@ impl Engine { } } +/// Equal apart from what changes without editing: the selection, the playhead and the zoom. +fn same_document(saved: &Session, doc: &Session) -> bool { + let mut doc = doc.clone(); + let (from, to) = (&saved.edit, &mut doc.edit); + to.selection = from.selection; + to.timeline_selection = from.timeline_selection; + to.selected_tracks.clone_from(&from.selected_tracks); + to.selected_clips.clone_from(&from.selected_clips); + to.playhead = from.playhead; + to.zoom = from.zoom; + *saved == doc +} + fn install_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { @@ -329,6 +380,10 @@ fn install_panic_hook() { }); } +fn panic_message(p: &(dyn std::any::Any + Send)) -> String { + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) +} + #[cfg(test)] mod merge_tests { use super::*; @@ -346,3 +401,27 @@ mod merge_tests { assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0); } } + +#[cfg(test)] +mod dirty_tests { + use super::*; + + #[test] + fn selecting_locating_and_zooming_leave_the_session_clean() { + let mut e = crate::demo::demo_engine(); + for (id, p) in [ + ("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})), + ("edit.select_none", json!({})), + ("transport.locate", json!({"at": 96_000})), + ("view.zoom_in", json!({})), + ("view.scroll", json!({"to": 48_000})), + ] { + e.execute(id, &p).unwrap(); + assert!(!e.is_dirty(), "{id}"); + } + e.execute("mix.mute", &json!({"track": "Kick"})).unwrap(); + assert!(e.is_dirty()); + e.undo(); + assert!(!e.is_dirty()); + } +} diff --git a/crates/engine/tests/hostile_params.rs b/crates/engine/tests/hostile_params.rs index 8a2a382..5aa6d39 100644 --- a/crates/engine/tests/hostile_params.rs +++ b/crates/engine/tests/hostile_params.rs @@ -45,6 +45,9 @@ const KEYS: &[&str] = &[ "numerator", "denominator", "factor", + "x", + "by_px", + "samples_per_px", "sensitivity", "rating", "direction", diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index b24b251..bd63c38 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -580,6 +580,10 @@ impl Player { self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open()) } + pub fn any_editor_open(&self) -> bool { + self.side().editors.values().any(|e| e.is_open()) + } + /// Shows a parameter change made elsewhere in the plugin's open editor. pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) { if let Some(e) = self.side().editors.get_mut(&(track, slot)) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 93b0626..2ea380e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -7,6 +7,7 @@ //! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect` //! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs) //! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}` +//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch) //! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}` //! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}` //! - `app.quit` @@ -91,6 +92,8 @@ fn mods(p: &Value) -> egui::Modifiers { fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option { let p = &req.params; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let pos = egui::pos2(f("x"), f("y")); + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); Some(match req.method.as_str() { "engine.execute" | "command" => { let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or(""); @@ -147,11 +150,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Err(e) => err(e), }, "ui.move" => { - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + app.synthetic.push(egui::Event::PointerMoved(pos)); ok(json!({})) } "ui.click" => { - let pos = egui::pos2(f("x"), f("y")); let button = match p.get("button").and_then(Value::as_str) { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, @@ -166,9 +168,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti ok(json!({})) } "ui.drag" => { - let a = egui::pos2(f("x"), f("y")); - let b = egui::pos2(f("to_x"), f("to_y")); - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); + let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y"))); let m = mods(p); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); @@ -179,6 +179,27 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); ok(json!({})) } + "ui.scroll" => { + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32; + let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p)); + let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers }; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO))); + app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta))); + app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO))); + ok(json!({})) + } + "ui.zoom" => { + let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else { + return Some(err("ui.zoom needs a positive factor (> 1 zooms in)")); + }; + let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each))); + ok(json!({})) + } "ui.key" => { let name = p.get("key").and_then(Value::as_str).unwrap_or(""); let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 15d2b25..2f1a34b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -24,11 +24,14 @@ pub enum Dialog { Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, Session { frame_rate: String, bit_depth: String }, ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool }, + SaveChanges { then: (String, Value) }, } #[derive(Default)] pub struct Dialogs { pub open: Option, + /// Runs once the session has been saved from `SaveChanges`. + after_save: Option<(String, Value)>, } impl Dialogs { @@ -48,6 +51,7 @@ impl Dialogs { Dialog::Group { .. } => "group", Dialog::Session { .. } => "session", Dialog::ScoreSetup { .. } => "score_setup", + Dialog::SaveChanges { .. } => "save_changes", }) } @@ -68,6 +72,7 @@ impl Dialogs { /// Open the dialog for a command; false when the command has no dialog. pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool { + self.after_save = None; let home = default_dir(); let name = e.session().name.clone(); self.open = Some(match id { @@ -85,10 +90,16 @@ impl Dialogs { "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, "file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() }, "session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() }, - "session.save_as" | "session.save_copy" => Dialog::PathPrompt { - cmd: id.into(), + "session.save" if e.path.is_some() => return false, + "session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt { + cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(), title: "Save Session As".into(), - path: format!("{home}/{name}/{name}.scraft"), + // The browser has no folders: the name becomes the bundle file's name. + path: if home.is_empty() { + format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION) + } else { + format!("{home}/{name}/{name}.scraft") + }, key: "path".into(), }, "edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() }, @@ -113,6 +124,11 @@ impl Dialogs { let s = soundcraft_engine::score::ScoreSetup::from_session(e.session()); Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names } } + "clip.rename" => { + let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id)); + let Some((_, clip)) = target else { return false }; + Dialog::RenameClip { id: clip.id, name: clip.name.clone() } + } _ => match path_dialog(id, &home, &name) { Some(d) => d, None => return false, @@ -122,6 +138,32 @@ impl Dialogs { } } +/// When `id` would close a session with unsaved changes, ask first; true when the question is +/// open instead. A take in progress is stopped first, so it counts as a change. +pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool { + if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit") + || soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err()) + { + return false; + } + if app.engine.transport.recording { + app.stop_play(); + } + if !app.engine.is_dirty() { + return false; + } + app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) }); + true +} + +/// Runs `id`, unless it asks to save first (`ask_to_save`). +pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result { + if ask_to_save(app, id, ¶ms) { + return Ok(Value::Null); + } + app.run(id, params) +} + /// A path prompt for any command whose parameters start with a required `path` or `dir` (plus /// Print Score, whose path is optional), with a default file name from the command. pub fn takes_path(id: &str) -> bool { @@ -211,6 +253,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; + let mut save_first: Option<(String, Value)> = None; + let confirmed = matches!(d, Dialog::SaveChanges { .. }); let title = match &d { Dialog::NewTracks { .. } => "New Tracks", Dialog::NewSession { .. } => "New Session", @@ -225,6 +269,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Group { .. } => "Create Group", Dialog::Session { .. } => "Session Setup", Dialog::ScoreSetup { .. } => "Score Setup", + Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved", + Dialog::SaveChanges { .. } => "Unsaved Changes", } .to_string(); let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)); @@ -322,15 +368,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::RenameTrack { id, name } => { - let r = ui.text_edit_singleline(name); - r.request_focus(); + name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("track.rename".into(), json!({"track": id.0, "name": name}))); } } Dialog::RenameClip { id, name } => { - let r = ui.text_edit_singleline(name); - r.request_focus(); + name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } @@ -476,12 +520,54 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { )); } } + Dialog::SaveChanges { then } => { + let name = &app.engine.session().name; + let revert = then.0 == "session.revert"; + if revert || cfg!(target_arch = "wasm32") { + let (question, ok) = if revert { + (format!("Revert “{name}” to its last saved version?"), "Revert") + } else { + (format!("Close “{name}” without saving?"), "Don't Save") + }; + ui.label(format!("{question} Your changes will be lost.")); + if buttons(ui, ok, false) { + action = Some(then.clone()); + } + } else { + ui.label(format!("Save changes to “{name}” before closing?")); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Don't Save").clicked() { + action = Some(then.clone()); + } + if buttons(ui, "Save", enter) { + save_first = Some(then.clone()); + } + }); + } + } } }); if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::new("dlg_cancel")).unwrap_or(false)) { keep = false; + app.dialogs.after_save = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::new("dlg_cancel"))); } + if let Some(then) = save_first { + keep = false; + if !app.dialogs.open_for_command(&app.engine, "session.save") { + action = Some(("session.save".into(), json!({}))); + } + app.dialogs.after_save = Some(then); + } + if let Some((cmd, params)) = &action + && !confirmed + && ask_to_save(app, cmd, params) + { + keep = false; + action = None; + } + let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save")); if let Some((cmd, params)) = action { keep = false; match app.run(&cmd, params) { @@ -492,6 +578,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Err(e) => app.ui.status = e, } } + if saving + && let Some((then, params)) = app.dialogs.after_save.take() + && !app.engine.is_dirty() + && let Err(e) = app.run(&then, params) + { + app.ui.status = e; + } if keep { app.dialogs.open = Some(d); } @@ -512,6 +605,17 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { pressed } +/// A name field that takes the focus with its text selected, so typing replaces the old name. +fn name_field(ui: &mut egui::Ui, name: &mut String) { + let mut out = egui::TextEdit::singleline(name).show(ui); + if !out.response.has_focus() { + out.response.request_focus(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); + out.state.cursor.set_char_range(Some(all)); + out.state.store(ui.ctx(), out.response.id); + } +} + /// The command the tempo-change dialog runs on OK. fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { ("event.tempo".into(), json!({"bpm": bpm, "at": at})) @@ -591,6 +695,18 @@ mod tests { assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + #[test] + fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() { + let mut e = soundcraft_engine::demo::demo_engine(); + let mut d = Dialogs::default(); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(!d.open_for_command(&e, "clip.rename")); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap(); + assert!(d.open_for_command(&e, "clip.rename")); + assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name)); + } + #[test] fn tempo_change_dialog_runs_a_real_command_at_its_position() { let (cmd, params) = tempo_change_action(96_000, 133.5); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 906ae1e..f13bc78 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -44,6 +44,25 @@ pub struct EditLayout { pub last_scroll: Samples, /// Follow target issued by the last overlay frame (`None` when none). pub last_follow_to: Option, + #[serde(skip)] + pub scroll_carry: f64, + /// The current swipe's movement per axis until either passes 6 points; it scrolls only along the larger. + #[serde(skip)] + swipe: egui::Vec2, +} + +impl EditLayout { + pub fn scroll_by(&mut self, dy: f32, view_h: f32) { + self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0)); + } +} + +pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect { + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) +} + +fn lane_rect(track: &Track, row: Rect, tl: Rect) -> Rect { + Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + track.height.points())) } pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { @@ -63,6 +82,7 @@ pub fn track_height(t: &Track) -> f32 { pub fn show(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); + drive_drag(app, ui.ctx()); egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); @@ -188,44 +208,28 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; ui.painter().rect_filled(full, 0.0, t.window_bg); - // Wheel: vertical scroll tracks, shift/horizontal scroll timeline, cmd = zoom. - let hovered = ui.rect_contains_pointer(Rect::from_min_max(pos2(full.min.x, tl.min.y), full.max)); - if hovered { - let (delta, mods) = ui.input(|i| (i.smooth_scroll_delta, i.modifiers)); - if mods.command && delta.y.abs() > 0.0 { - let id = if delta.y > 0.0 { "view.zoom_in" } else { "view.zoom_out" }; - let _ = app.run(id, json!({})); - } else { - let dx = if mods.shift { delta.y } else { delta.x }; - if dx.abs() > 0.0 { - let _ = app.engine.execute("view.scroll", &json!({"by_px": -dx})); - } - if !mods.shift && delta.y.abs() > 0.0 { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); - } - } - } + scroll_and_zoom(app, ui, full, tl); draw_rulers(app, ui, rulers_rect, tl, &rulers); // Tracks. - let ids: Vec = - app.engine.session().tracks.iter().filter(|x| !x.hidden && !folder_collapsed(app.engine.session(), x)).map(|x| x.id).collect(); let mut y = tl.min.y - app.edit_layout.scroll_y; app.edit_layout.rows.clear(); - let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); let mut content_h = 0.0; - for id in ids { - let Some(h) = app.engine.session().track(id).map(track_height) else { continue }; - let row = Rect::from_min_max(pos2(full.min.x, y), pos2(tl.max.x, y + h)); - if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); - child.set_clip_rect(clip_rect); - track_row(app, &mut child, id, row, tl); - } - app.edit_layout.rows.push((id.0, [row.min.x, row.min.y, row.max.x, row.max.y])); + for t in app.engine.session().tracks.iter().filter(|x| in_edit_window(app.engine.session(), x)) { + let h = track_height(t); + app.edit_layout.rows.push((t.id.0, [full.min.x, y, tl.max.x, y + h])); y += h + 1.0; content_h += h + 1.0; } app.edit_layout.content_h = content_h; + let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); + for (id, r) in app.edit_layout.rows.clone() { + let row = rect(r); + if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); + child.set_clip_rect(clip_rect); + track_row(app, &mut child, TrackId(id), row, tl); + } + } // Empty area click clears track selection. let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() { @@ -246,21 +250,79 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(thumb, 3.0, t.button_hi); let resp = ui.interact(vs, ui.id().with("vscroll"), Sense::drag()); if resp.dragged() { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y + resp.drag_delta().y / frac).clamp(0.0, (content_h - tl.height() + 40.0).max(0.0)); + app.edit_layout.scroll_by(resp.drag_delta().y / frac, tl.height()); } } } -fn folder_collapsed(s: &Session, t: &Track) -> bool { - t.folder.and_then(|f| s.track(f)).is_some_and(|f| !f.folder_open) +fn in_edit_window(s: &Session, t: &Track) -> bool { + !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open) +} + +/// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. +pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers, shown: impl Fn(&Session, &Track) -> bool) { + let s = app.engine.session(); + let order: Vec = s.tracks.iter().filter(|t| shown(s, t)).map(|t| t.id).collect(); + let row = |t: &TrackId| order.iter().position(|o| o == t); + let mut tracks = s.edit.selected_tracks.clone(); + if mods.command { + if tracks.contains(&id) { + tracks.retain(|t| *t != id); + } else { + tracks.push(id); + } + } else if mods.shift + && let Some(a) = row(&id) + && let Some(b) = tracks.iter().filter_map(row).min_by_key(|b| b.abs_diff(a)) + { + let span = order.get(a.min(b)..=a.max(b)).unwrap_or_default(); + tracks.extend(span.iter().filter(|t| !tracks.contains(t)).copied().collect::>()); + } else { + tracks = vec![id]; + } + let ids: Vec = tracks.iter().map(|t| t.0).collect(); + let _ = app.run("edit.select", json!({"tracks": ids})); +} + +fn scroll_and_zoom(app: &mut SoundApp, ui: &Ui, area: Rect, tl: Rect) { + let (delta, zoom, pointer, scrolling) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.is_scrolling())); + let mut swipe = if scrolling { app.edit_layout.swipe } else { egui::Vec2::ZERO }; + if swipe.max_elem() < 6.0 { + swipe += delta.abs(); + } + app.edit_layout.swipe = swipe; + let Some(p) = pointer.filter(|_| tl.is_positive() && ui.rect_contains_pointer(area)) else { return }; + if zoom != 1.0 { + let _ = app.engine.execute("view.zoom_by", &json!({"factor": zoom, "x": p.x.clamp(tl.min.x, tl.max.x) - tl.min.x})); + } + let horizontal = swipe.x > swipe.y; + if horizontal && delta.x != 0.0 { + pan(app, -f64::from(delta.x)); + } + if !horizontal && delta.y != 0.0 { + app.edit_layout.scroll_by(-delta.y, tl.height()); + } +} + +fn pan(app: &mut SoundApp, px: f64) { + let z = app.engine.session().edit.zoom; + let want = (z.scroll as f64 + app.edit_layout.scroll_carry + px * z.samples_per_px).clamp(0.0, soundcraft_engine::cmd::MAX_POSITION as f64); + if want.is_nan() { + return; + } + let to = soundcraft_time::to_samples(want); + app.edit_layout.scroll_carry = want - to as f64; + if to != z.scroll { + let _ = app.engine.execute("view.scroll", &json!({"to": to})); + } } fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { let t = Tokens::current(); ui.painter().rect_filled(sb, 0.0, t.panel_bg); let s = app.engine.session(); - let total = (s.content_end() + s.sample_rate.samples(30.0)).max(1) as f64; let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px; + let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis); let frac = (vis / total).clamp(0.02, 1.0) as f32; let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32; let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0)); @@ -269,7 +331,7 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { if resp.dragged() { let dx = resp.drag_delta().x / sb.width(); let by_px = f64::from(dx) * total / s.edit.zoom.samples_per_px; - let _ = app.engine.execute("view.scroll", &json!({"by_px": by_px})); + pan(app, by_px); } } @@ -625,7 +687,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) header_column(app, ui, &track, c, cr); } let main_h = track.height.points(); - let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + main_h)); + let lane = lane_rect(&track, row, tl); // Header background. ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); @@ -729,15 +791,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele // Click on header background selects the track. let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click()); if bg.clicked() { - let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); - let mut tracks = if add { app.engine.session().edit.selected_tracks.clone() } else { Vec::new() }; - if add && tracks.contains(&id) { - tracks.retain(|x| *x != id); - } else { - tracks.push(id); - } - let ids: Vec = tracks.iter().map(|x| x.0).collect(); - let _ = app.run("edit.select", json!({"tracks": ids})); + click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } bg.context_menu(|ui| track_context_menu(app, ui, id)); // Name field. @@ -760,7 +814,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } else if nresp.clicked() { - let _ = app.run("edit.select", json!({"tracks": [id.0]})); + click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } // Drag the name plate to reorder tracks. if nresp.dragged() @@ -1437,14 +1491,16 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let mods = ui.input(|i| i.modifiers); let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; let at = sample_at(&s, tl, p.x).max(0); - let hit = clip_at(track, at).cloned(); - let upper = p.y < lane.center().y; + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; + let at_origin = sample_at(&s, tl, origin.x).max(0); + let hit = clip_at(track, at_origin).cloned(); + let upper = origin.y < lane.center().y; let near_edge = |c: &Clip| -> Option { let xs = x_of(&s, tl, c.start); let xe = x_of(&s, tl, c.end()); - if (p.x - xs).abs() < 6.0 { + if (origin.x - xs).abs() < 6.0 { Some(true) - } else if (p.x - xe).abs() < 6.0 { + } else if (origin.x - xe).abs() < 6.0 { Some(false) } else { None @@ -1483,12 +1539,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, hit.as_ref().and_then(|c| { let xs = x_of(&s, tl, c.start); let xe = x_of(&s, tl, c.end()); - let top = p.y < lane.min.y + lane.height() * 0.28; + let top = origin.y < lane.min.y + lane.height() * 0.28; if !top { None - } else if (p.x - xs) > 0.0 && (p.x - xs) < 14.0 { + } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { Some(true) - } else if (xe - p.x) > 0.0 && (xe - p.x) < 14.0 { + } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { Some(false) } else { None @@ -1507,8 +1563,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.hovered() && gain_icon_at(p).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); - let gain_drag = if resp.drag_started() { gain_icon_at(press) } else { None }; + let selector_press = eff == Tool::Selector && fade_corner.is_none() && gain_icon_at(origin).is_none(); + if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { + place_insertion(app, ui, &s, track.id, at, mods.shift); + } + let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None }; if let Some(c) = gain_drag { // `clip.gain` refuses to run with nothing selected. if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { @@ -1516,151 +1575,49 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } let pos = crate::widgets::clip_gain_to_pos(c.gain_db); let screen = ui.ctx().content_rect(); - let y = (press.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); - let fader_bottom = pos2(press.x.max(screen.min.x + 26.0), y); + let y = (origin.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); + let fader_bottom = pos2(origin.x.max(screen.min.x + 26.0), y); let fine = mods.command || mods.ctrl; - app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (press.y, pos), fine, fader_bottom }); + app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (origin.y, pos), fine, fader_bottom }); } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); } else if resp.drag_started() { app.gesture = match eff { - Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at), tracks: vec![track.id] }), + Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }), Tool::Grabber => hit.as_ref().map(|c| { let clips = if s.edit.selected_clips.contains(&c.id) { s.edit.selected_clips.clone() } else { vec![c.id] }; - Gesture::MoveClips { clips, grab_at: at, delta: 0, from_track: track.id, to_track: track.id } + Gesture::MoveClips { clips, grab_at: at_origin, delta: 0, from_track: track.id, to_track: track.id } }), Tool::Trim => hit.as_ref().map(|c| match near_edge(c) { Some(false) => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, - _ if at - c.start < c.end() - at => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, + _ if at_origin - c.start < c.end() - at_origin => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, }), Tool::Scrubber => Some(Gesture::Scrub { last: at }), Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), - Tool::Zoom => Some(Gesture::ZoomBox { start: p.x }), + Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; } - if resp.dragged() { - let to_track = app.edit_layout.rows.iter().find(|(_, r)| p.y >= r[1] && p.y <= r[3]).map(|(i, _)| TrackId(*i)); - match &mut app.gesture { - Some(Gesture::Select { tracks, .. }) => { - if let Some(t2) = to_track { - // Extend across the tracks between the anchor row and the pointer row. - let order: Vec = app.edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect(); - let a = order.iter().position(|x| *x == track.id).unwrap_or(0); - let b = order.iter().position(|x| *x == t2).unwrap_or(a); - let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; - *tracks = order.get(lo..=hi).map(<[TrackId]>::to_vec).unwrap_or_default(); - } - } - Some(Gesture::MoveClips { grab_at, delta, to_track: tt, .. }) => { - let raw = at - *grab_at; - *delta = if s.edit.edit_mode.is_grid() { - snap(&s, hit.as_ref().map_or(*grab_at, |c| c.start) + raw) - hit.as_ref().map_or(*grab_at, |c| c.start) - } else { - raw - }; - if s.edit.edit_mode == soundcraft_model::EditMode::Spot { - *delta = 0; - } - if let Some(t2) = to_track { - *tt = t2; - } - } - Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), - Some(Gesture::Fade { to, .. }) => *to = at, - Some(Gesture::ClipGain { clip, pos, anchor, fine, .. }) => { - // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. - if *fine != (mods.command || mods.ctrl) { - *fine = !*fine; - *anchor = (p.y, *pos); - } - let scale = if *fine { 0.1 } else { 1.0 }; - *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); - let db = crate::widgets::clip_gain_from_pos(*pos); - if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { - let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); - } - } - Some(Gesture::Scrub { last }) => { - *last = at; - let _ = app.engine.execute("transport.locate", &json!({"at": at})); - } - Some(Gesture::Pencil { points, .. }) => { - if let Some(param) = &auto_view { - let (lo, hi, _) = param.range(); - let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { - (-60.0, 12.0) - } else if matches!(param, AutoParam::Plugin { .. }) { - plugin_range(track, param) - } else { - (lo, hi) - }; - let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); - let v = lo + (hi - lo) * k; - if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { - points.push((at, v)); - } - } - } - _ => {} - } - // Live selection feedback. - if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { - let r = Range::new(*anchor, snap(&s, at)); - let ids: Vec = tracks.iter().map(|x| x.0).collect(); - let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end})); - } - } - if let Some(Gesture::ClipGain { track: t, pos, fader_bottom, .. }) = &app.gesture + if let Some(Gesture::ClipGain { clip, track: t, pos, anchor, fine, fader_bottom }) = &mut app.gesture && *t == track.id && resp.dragged() { + // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. + if *fine != (mods.command || mods.ctrl) { + *fine = !*fine; + *anchor = (p.y, *pos); + } + let scale = if *fine { 0.1 } else { 1.0 }; + *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); + let db = crate::widgets::clip_gain_from_pos(*pos); + if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { + let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); + } ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } - if resp.drag_stopped() { - let g = app.gesture.take(); - match g { - Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { - let ids: Vec = clips.iter().map(|c| c.0).collect(); - let mut params = json!({"clips": ids, "by": delta}); - if to_track != from_track { - params["track"] = json!(to_track.0); - } - if delta != 0 || to_track != from_track { - let _ = app.run("edit.move_clips", params); - } - } - Some(Gesture::Fade { clip, track, fade_in, to }) => { - if let Some((_, c)) = app.engine.session().find_clip(clip).map(|(t, c)| (t, c.clone())) { - let (a, b) = if fade_in { (c.start, to.clamp(c.start + 1, c.end())) } else { (to.clamp(c.start, c.end() - 1), c.end()) }; - let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); - } - } - Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), - Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), - Some(Gesture::Pencil { track, points }) => { - if let Some(param) = &auto_view { - let pid = automation_id(param); - for (a, v) in points { - let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); - } - } - } - Some(Gesture::ZoomBox { start }) => { - let a = sample_at(&s, tl, start.min(p.x)); - let b = sample_at(&s, tl, start.max(p.x)); - if b > a { - let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); - let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); - let _ = app.run("view.scroll", json!({"to": a})); - } - } - _ => {} - } - } if resp.clicked() { match eff { Tool::Grabber => { @@ -1684,16 +1641,8 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ); } } - _ => { - let at = snap(&s, at); - if mods.shift && s.edit.selected_tracks.contains(&track.id) { - let sel = s.edit.selection; - let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; - let _ = app.run("edit.select", json!({"start": r.start, "end": r.end})); - } else { - let _ = app.run("edit.select", json!({"tracks": [track.id.0], "start": at})); - } - } + _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), + _ => {} } } if resp.double_clicked() @@ -1704,6 +1653,153 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } +fn place_insertion(app: &mut SoundApp, ui: &Ui, s: &Session, track: TrackId, at: Samples, extend: bool) { + let at = snap(s, at); + let sel = s.edit.selection; + let params = if extend && s.edit.selected_tracks.contains(&track) { + let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; + json!({"start": r.start, "end": r.end}) + } else if extend && !s.edit.selected_tracks.is_empty() { + let tracks: Vec = s.edit.selected_tracks.iter().chain([&track]).map(|t| t.0).collect(); + json!({"tracks": tracks, "start": sel.start.min(at), "end": sel.end.max(at)}) + } else { + json!({"tracks": [track.0], "start": at}) + }; + let rev = app.engine.revision; + let _ = app.run("edit.select", params); + if app.engine.revision != rev { + ui.ctx().request_discard("insertion point moved"); + } +} + +/// Runs before anything is painted, against the last frame's layout. +fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { + if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { + return; + } + let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline)); + let p = ctx.pointer_latest_pos(); + if !ctx.input(|i| i.pointer.any_down()) { + finish_drag(app, &s, tl, p); + return; + } + let Some(p) = p else { return }; + let at = sample_at(&s, tl, p.x).max(0); + let inside = pos2(p.x.max(tl.min.x).min(tl.max.x), p.y.max(tl.min.y).min(tl.max.y)); + let rows = &app.edit_layout.rows; + let gap = |r: &[f32; 4]| (r[1] - inside.y).max(inside.y - r[3]).max(0.0); + let to_track = rows.iter().min_by(|a, b| gap(&a.1).total_cmp(&gap(&b.1))).map(|(i, _)| TrackId(*i)); + match &mut app.gesture { + Some(Gesture::Select { track, tracks, .. }) => { + let row_of = |t: TrackId| rows.iter().position(|(i, _)| *i == t.0); + if let (Some(a), Some(b)) = (row_of(*track), to_track.and_then(row_of)) { + *tracks = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| TrackId(*i)).collect(); + } + } + Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { + let raw = at - *grab_at; + *delta = if s.edit.edit_mode.is_grid() { + let base = s.track(*from_track).and_then(|t| clip_at(t, *grab_at)).map_or(*grab_at, |c| c.start); + snap(&s, base + raw) - base + } else { + raw + }; + if s.edit.edit_mode == soundcraft_model::EditMode::Spot { + *delta = 0; + } + if let Some(t2) = to_track { + *tt = t2; + } + } + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::Scrub { last }) => { + *last = at; + let _ = app.engine.execute("transport.locate", &json!({"at": at})); + } + Some(Gesture::Pencil { track, points }) => { + if let Some(t) = s.track(*track) + && let Some(param) = AutoParam::parse(&t.view) + && let Some((_, row)) = rows.iter().find(|(i, _)| *i == t.id.0) + { + let lane = lane_rect(t, rect(*row), tl); + let (lo, hi, _) = param.range(); + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0, 12.0) + } else if matches!(param, AutoParam::Plugin { .. }) { + plugin_range(t, ¶m) + } else { + (lo, hi) + }; + let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); + if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { + points.push((at, lo + (hi - lo) * k)); + } + } + } + _ => {} + } + if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { + let r = Range::new(*anchor, snap(&s, at)); + if *tracks != s.edit.selected_tracks || r != s.edit.selection { + let ids: Vec = tracks.iter().map(|x| x.0).collect(); + let _ = app.run("edit.select", json!({"tracks": ids, "start": r.start, "end": r.end})); + } + } + let out = p - inside; + if out != egui::Vec2::ZERO && matches!(app.gesture, Some(Gesture::Select { .. } | Gesture::MoveClips { .. })) { + let k = 12.0 * ctx.input(|i| i.stable_dt).min(0.1); + if out.x != 0.0 { + let _ = app.engine.execute("view.scroll", &json!({"by_px": out.x * k})); + } + app.edit_layout.scroll_by(out.y * k, tl.height()); + ctx.request_repaint(); + } +} + +fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { + match app.gesture.take() { + Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { + let ids: Vec = clips.iter().map(|c| c.0).collect(); + let mut params = json!({"clips": ids, "by": delta}); + if to_track != from_track { + params["track"] = json!(to_track.0); + } + if delta != 0 || to_track != from_track { + let _ = app.run("edit.move_clips", params); + } + } + Some(Gesture::Fade { clip, track, fade_in, to }) => { + if let Some((_, c)) = s.find_clip(clip) { + // Not clamp: it panics on a zero-length clip. + let (a, b) = if fade_in { (c.start, to.max(c.start + 1).min(c.end())) } else { (to.max(c.start).min(c.end() - 1), c.end()) }; + let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); + } + } + Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), + Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), + Some(Gesture::Pencil { track, points }) => { + if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { + let pid = automation_id(¶m); + for (a, v) in points { + let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); + } + } + } + Some(Gesture::ZoomBox { start }) => { + let Some(p) = p else { return }; + let a = sample_at(s, tl, start.min(p.x)); + let b = sample_at(s, tl, start.max(p.x)); + if b > a { + let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); + let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); + let _ = app.run("view.scroll", json!({"to": a})); + } + } + _ => {} + } +} + fn automation_id(p: &AutoParam) -> String { match p { AutoParam::Volume => "volume".into(), @@ -1833,15 +1929,25 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } else if !app.is_playing() { let x = x_of(s, tl, sel.start); - let blink = (ui.input(|i| i.time) * 2.0) as i64 % 2 == 0; - if blink { - for (_, r) in &app.edit_layout.rows { - if s.edit.selected_tracks.iter().any(|_| true) { + let now = ui.input(|i| i.time); + let (id, key) = (ui.id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone())); + let since = ui.ctx().data_mut(|d| { + let (last, since) = d.get_temp_mut_or_insert_with(id, || (key.clone(), now)); + if *last != key { + *last = key; + *since = now; + } + *since + }); + let phase = (now - since) * 2.0; + if (phase as i64) % 2 == 0 { + for (id, r) in &app.edit_layout.rows { + if s.edit.selected_tracks.contains(&TrackId(*id)) { painter.line_segment([pos2(x, r[1].max(tl.min.y)), pos2(x, r[3])], Stroke::new(1.0, t.insertion)); } } } - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + ui.ctx().request_repaint_after_secs(((1.0 - phase.fract()) / 2.0) as f32); } if app.is_playing() { let x = x_of(s, tl, app.position()); @@ -1899,7 +2005,8 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { #[cfg(test)] mod tests { - use super::follow_update; + use super::*; + use egui_kittest::Harness; #[test] fn follow_jumps_to_offscreen_playhead_while_playing() { @@ -1939,4 +2046,415 @@ mod tests { assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None)); assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None)); } + + fn harness(n: usize) -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"count": n})).unwrap(); + harness_with(engine) + } + + fn harness_with(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { + let app = SoundApp::new(engine, None, crate::Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(4); + h + } + + fn smart_demo() -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.execute("edit.tool", &json!({"tool": "smart"})).unwrap(); + engine.execute("edit.mode", &json!({"mode": "slip"})).unwrap(); + harness_with(engine) + } + + fn row(h: &Harness, i: usize) -> Rect { + rect(h.state().edit_layout.rows[i].1) + } + + fn row_ids(h: &Harness) -> Vec { + h.state().edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect() + } + + fn timeline(h: &Harness) -> Rect { + rect(h.state().edit_layout.timeline) + } + + fn lane_point(h: &Harness, i: usize) -> Pos2 { + pos2(timeline(h).min.x + 100.0, row(h, i).min.y + 10.0) + } + + fn clip_on_screen(h: &Harness, ok: impl Fn(f32, f32) -> bool) -> (Clip, Rect) { + let (s, tl) = (h.state().engine.session(), timeline(h)); + h.state() + .edit_layout + .rows + .iter() + .filter_map(|(id, r)| s.track(TrackId(*id)).map(|t| (t, r))) + .find_map(|(t, r)| { + let c = t.clips().iter().find(|c| ok(x_of(s, tl, c.start), x_of(s, tl, c.end())))?; + Some((c.clone(), lane_rect(t, rect(*r), tl))) + }) + .expect("no such clip on screen") + } + + fn start_drag(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.hover_at(at + vec2(20.0, 0.0)); + h.step(); + } + + fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + h.hover_at(from); + h.drag_at(from); + for k in 1..=8 { + h.hover_at(from + (to - from) * (k as f32 / 8.0)); + } + h.drop_at(to); + h.step(); + } + + fn quick_drag(h: &mut Harness, at: Pos2, to: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.hover_at(at + (to - at) * 0.75); + h.hover_at(to); + h.drop_at(to); + h.step(); + } + + fn click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.step(); + } + + fn click_with(h: &mut Harness, at: Pos2, mods: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(mods)); + click(h, at); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + + fn painted_rows(h: &Harness, y_of: impl Fn(&Shape) -> Option) -> Vec { + let rows = &h.state().edit_layout.rows; + let mut out: Vec = h + .output() + .shapes + .iter() + .filter_map(|c| y_of(&c.shape)) + .filter_map(|y| rows.iter().position(|(_, r)| (r[1]..=r[3]).contains(&y))) + .collect(); + out.sort_unstable(); + out.dedup(); + out + } + + fn highlighted_rows(h: &Harness) -> Vec { + painted_rows(h, |shape| match shape { + Shape::Rect(r) if r.fill == Tokens::current().selection => Some(r.rect.center().y), + _ => None, + }) + } + + fn insertion_rows(h: &Harness) -> Vec { + painted_rows(h, |shape| match shape { + Shape::LineSegment { points, stroke } if stroke.color == Tokens::current().insertion => Some((points[0].y + points[1].y) / 2.0), + _ => None, + }) + } + + #[test] + fn drag_selection_spans_the_rows_from_anchor_to_pointer() { + let mut h = harness(4); + let ids = row_ids(&h); + for (from, to) in [(3, 2), (0, 1), (2, 0), (1, 3)] { + let (a, b) = (lane_point(&h, from), lane_point(&h, to) + vec2(80.0, 0.0)); + drag(&mut h, a, b); + let want = ids.get(from.min(to)..=from.max(to)).unwrap(); + assert_eq!(h.state().engine.session().edit.selected_tracks, want, "drag from row {from} to row {to}"); + } + } + + #[test] + fn the_frame_a_drag_reaches_a_row_paints_it_selected() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 2) + vec2(60.0, 0.0)); + start_drag(&mut h, a); + h.hover_at(b); + h.step(); + assert_eq!(highlighted_rows(&h), [2, 3]); + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_paints_each_frame_once() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 0) + vec2(300.0, 0.0)); + start_drag(&mut h, a); + for k in 1..=10 { + h.hover_at(a + (b - a) * (k as f32 / 10.0)); + h.step(); + assert_eq!(h.output().platform_output.num_completed_passes, 1, "move {k}"); + } + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_frame_changes_the_document_once() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 2) + vec2(60.0, 0.0)); + start_drag(&mut h, a); + let rev = h.state().engine.revision; + h.hover_at(b); + h.step(); + assert_eq!(h.state().engine.revision, rev + 1); + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_selection_starts_where_the_button_went_down() { + let mut h = harness(4); + let a = lane_point(&h, 1); + drag(&mut h, a, a + vec2(200.0, 0.0)); + let s = h.state().engine.session(); + assert_eq!(s.edit.selection.start, sample_at(s, timeline(&h), a.x)); + } + + #[test] + fn pressing_in_a_lane_places_the_insertion_point_before_release() { + let mut h = harness(4); + let a = lane_point(&h, 2); + h.hover_at(a); + h.drag_at(a); + h.step(); + let s = h.state().engine.session(); + assert_eq!(s.edit.selection, Range::point(sample_at(s, timeline(&h), a.x))); + assert_eq!(s.edit.selected_tracks, row_ids(&h)[2..3]); + h.drop_at(a); + h.step(); + } + + #[test] + fn a_moved_insertion_point_shows_at_once_even_mid_blink() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 0), lane_point(&h, 2) + vec2(150.0, 0.0)); + click(&mut h, a); + for _ in 0..90 { + if insertion_rows(&h).is_empty() { + break; + } + h.step(); + } + assert!(insertion_rows(&h).is_empty(), "the insertion point never blinked off"); + click(&mut h, b); + assert!(!insertion_rows(&h).is_empty()); + } + + #[test] + fn the_insertion_point_shows_only_on_selected_tracks() { + let mut h = harness(4); + let a = lane_point(&h, 2); + click(&mut h, a); + assert_eq!(insertion_rows(&h), [2]); + } + + #[test] + fn shift_click_adds_another_track_to_the_selection() { + let mut h = harness(4); + let ids = row_ids(&h); + let (a, b, c) = (lane_point(&h, 0), lane_point(&h, 2) + vec2(100.0, 0.0), lane_point(&h, 3) + vec2(300.0, 0.0)); + drag(&mut h, a, a + vec2(200.0, 0.0)); + let sel = h.state().engine.session().edit.selection; + click_with(&mut h, b, egui::Modifiers::SHIFT); + assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]]); + assert_eq!(h.state().engine.session().edit.selection, sel); + click_with(&mut h, c, egui::Modifiers::SHIFT); + let s = h.state().engine.session(); + assert_eq!(s.edit.selected_tracks, [ids[0], ids[2], ids[3]]); + assert_eq!(s.edit.selection, Range::new(sel.start, sample_at(s, timeline(&h), c.x))); + assert_eq!(highlighted_rows(&h), [0, 2, 3]); + } + + #[test] + fn shift_and_cmd_click_on_track_names_extend_the_selection() { + let mut h = harness(5); + let ids = row_ids(&h); + let (none, shift, cmd) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND); + for (i, mods, want) in [(1, none, vec![1]), (3, shift, vec![1, 2, 3]), (2, cmd, vec![1, 3]), (0, cmd, vec![0, 1, 3]), (4, none, vec![4])] { + let at = row(&h, i).min + vec2(60.0, 10.0); + click_with(&mut h, at, mods); + let want: Vec = want.into_iter().map(|k| ids[k]).collect(); + assert_eq!(h.state().engine.session().edit.selected_tracks, want, "row {i} clicked with {mods:?}"); + } + } + + #[test] + fn a_quick_drag_from_a_clip_edge_trims_it() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xe > tl.min.x + 100.0 && xe < tl.max.x - 20.0 && xe - xs > 100.0); + let press = pos2(x_of(h.state().engine.session(), tl, clip.end()) - 2.0, lane.min.y + lane.height() * 0.75); + quick_drag(&mut h, press, press - vec2(40.0, 0.0)); + let after = h.state().engine.session().find_clip(clip.id).unwrap().1.clone(); + assert_eq!(after.start, clip.start, "the clip moved instead of being trimmed"); + assert!(after.end() < clip.end(), "the clip end did not move"); + } + + #[test] + fn a_quick_clip_drag_keeps_the_clip_under_the_pointer() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xe - xs > 120.0 && (xs + xe) / 2.0 > tl.min.x + 60.0 && (xs + xe) / 2.0 < tl.max.x - 60.0); + let s = h.state().engine.session(); + let grab = pos2((x_of(s, tl, clip.start) + x_of(s, tl, clip.end())) / 2.0, lane.min.y + lane.height() * 0.75); + let by = sample_at(s, tl, grab.x + 40.0) - sample_at(s, tl, grab.x); + quick_drag(&mut h, grab, grab + vec2(40.0, 0.0)); + let after = h.state().engine.session().find_clip(clip.id).unwrap().1.clone(); + assert_eq!(after.start - clip.start, by, "the clip trails the pointer"); + } + + #[test] + fn a_fade_drag_survives_its_clip_shrinking_to_nothing() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 20.0 && xe - xs > 100.0); + let corner = pos2(x_of(h.state().engine.session(), tl, clip.start) + 6.0, lane.min.y + 4.0); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { .. }))); + if let Some(c) = h.state_mut().engine.session_mut().find_clip_mut(clip.id) { + c.length = 0; + } + h.drop_at(corner + vec2(20.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); + } + + #[test] + fn a_drag_keeps_going_when_its_row_scrolls_away() { + let mut h = harness(20); + let (ids, a) = (row_ids(&h), lane_point(&h, 0)); + start_drag(&mut h, a); + h.state_mut().edit_layout.scroll_y = 500.0; + h.step(); + assert!(row(&h, 0).max.y < timeline(&h).min.y, "the first row is still on screen"); + let b = lane_point(&h, 8); + h.hover_at(b); + h.drop_at(b); + h.step(); + assert_eq!(h.state().engine.session().edit.selected_tracks, ids[..=8]); + assert!(h.state().gesture.is_none(), "the drag outlived the release"); + } + + #[test] + fn a_gesture_left_over_after_the_release_is_dropped() { + let mut h = harness(1); + h.state_mut().gesture = Some(Gesture::Scrub { last: 0 }); + h.step(); + assert!(h.state().gesture.is_none()); + } + + #[test] + fn a_flick_past_the_last_track_selects_down_to_it() { + let mut h = harness(4); + let ids = row_ids(&h); + let a = lane_point(&h, 1); + let below = pos2(a.x + 60.0, row(&h, 3).max.y + 100.0); + start_drag(&mut h, a); + h.hover_at(below); + h.drop_at(below); + h.step(); + assert_eq!(h.state().engine.session().edit.selected_tracks, ids[1..]); + } + + #[test] + fn dragging_past_the_bottom_scrolls_and_keeps_selecting() { + let mut h = harness(20); + let first = row_ids(&h)[0]; + let a = lane_point(&h, 0); + let below = pos2(a.x + 60.0, timeline(&h).max.y + 40.0); + start_drag(&mut h, a); + h.hover_at(below); + h.step(); + h.run_steps(60); + assert!(h.state().edit_layout.scroll_y > 300.0, "scrolled {} px", h.state().edit_layout.scroll_y); + h.drop_at(below); + h.step(); + let tl = timeline(&h); + let last_seen = h.state().edit_layout.rows.iter().filter(|(_, r)| r[1] < tl.max.y).count(); + let sel = &h.state().engine.session().edit.selected_tracks; + assert_eq!((sel.first(), sel.len()), (Some(&first), last_seen)); + } + + #[test] + fn dragging_past_the_right_edge_scrolls_the_timeline() { + let mut h = harness(2); + let a = lane_point(&h, 0); + let right = pos2(timeline(&h).max.x + 40.0, a.y); + let s = h.state().engine.session(); + let (before, pointed) = (s.edit.zoom.scroll, sample_at(s, timeline(&h), right.x)); + start_drag(&mut h, a); + h.hover_at(right); + h.step(); + h.run_steps(30); + h.drop_at(right); + h.step(); + let s = h.state().engine.session(); + assert!(s.edit.zoom.scroll > before, "the timeline did not scroll"); + assert!(s.edit.selection.end > pointed, "the selection did not follow the scroll"); + } + + #[test] + fn a_redrawn_frame_counts_once_in_the_frame_time() { + let mut h = harness(4); + let mut t = 1.0; + let mut frame = |h: &mut Harness| { + t += 1.0 / 60.0; + h.input_mut().time = Some(t); + h.step(); + }; + for _ in 0..120 { + frame(&mut h); + } + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + for k in 0..30 { + let at = lane_point(&h, k % 4) + vec2(k as f32 * 7.0, 0.0); + h.hover_at(at); + frame(&mut h); + h.event(button(at, true)); + frame(&mut h); + h.event(button(at, false)); + frame(&mut h); + } + let ms = h.state().frame_ms; + assert!((ms - 1000.0 / 60.0).abs() < 0.5, "{ms} ms/frame"); + } + + #[test] + fn dragging_a_name_plate_down_drops_it_where_released() { + let mut h = harness(4); + let order = |h: &Harness| -> Vec { h.state().engine.session().tracks.iter().map(|t| t.id).collect() }; + let before = order(&h); + let grab = row(&h, 0).min + vec2(40.0, 12.0); + let release = pos2(grab.x, row(&h, 2).min.y); + drag(&mut h, grab, release); + let want: Vec = [1, 0, 2, 3].iter().map(|&i| before[i]).collect(); + assert_eq!(order(&h), want); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 305ff2e..5c4a751 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -234,7 +234,7 @@ pub struct SoundApp { pub autosave_dir: Option, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, - last_autosave: f64, + last_autosave: Option, pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, @@ -297,7 +297,7 @@ impl SoundApp { native_menu_bar: false, autosave_dir: None, preset_dir: None, - last_autosave: 0.0, + last_autosave: None, dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), @@ -511,15 +511,14 @@ impl SoundApp { } /// Every two minutes, copy a modified session to the autosave folder (not while recording). - fn autosave(&mut self, now: f64) { + /// Timed by the wall clock: egui's stands still while the window is hidden. + fn autosave(&mut self) { let Some(dir) = self.autosave_dir.clone() else { return }; - if self.last_autosave == 0.0 { - self.last_autosave = now; - } - if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording { + let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now); + if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording { return; } - self.last_autosave = now; + self.last_autosave = Some(std::time::Instant::now()); self.capture_plugin_states(); let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); let path = dir.join(&name).join(format!("{name}.scraft")); @@ -786,6 +785,9 @@ impl SoundApp { /// Per-frame logic: control channel, transport, document sync. pub fn logic(&mut self, ctx: &egui::Context) { + if ctx.current_pass_index() > 0 { + return; + } // Fonts set now take effect next frame, so draw only from the frame after. if !self.fonts_ready { if self.fonts_installed { @@ -802,8 +804,7 @@ impl SoundApp { self.theme_applied = Some(light); } let now = ctx.input(|i| i.time); - let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); - self.last_frame = Some(now); + 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.apply_editor_edits(); @@ -825,21 +826,17 @@ impl SoundApp { } } self.write_automation(ctx); - self.autosave(now); + self.autosave(); if self.recorder.is_none() && !self.recorder_failed && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm) { self.ensure_input(); } - if self.meters_live() { - let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 }; - ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz)); - } else if self.meters.values().any(|m| m.level[0] > 0.0001) { - ctx.request_repaint_after(std::time::Duration::from_millis(80)); - } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() { - ctx.request_repaint(); - } + // Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from. + // egui takes its predicted frame time off every delay, so add it back. + let editors = self.player.as_ref().is_some_and(Player::any_editor_open); + ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt)); control::collect_screenshots(self, ctx); } @@ -905,6 +902,15 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + // Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it. + if self.meters_live() { + let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 }; + ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz)); + } else if self.meters.values().any(|m| m.level[0] > 0.0001) { + ctx.request_repaint_after(std::time::Duration::from_millis(80)); + } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + ctx.request_repaint(); + } } /// UI state as JSON (control channel `ui.inspect`). @@ -992,6 +998,20 @@ mod tests { assert_eq!(app.engine.transport.position, 0); } + #[test] + fn a_hidden_window_still_autosaves() { + let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id())); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.autosave_dir = Some(dir.clone()); + app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap(); + app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121)); + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() }; + let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx)); + assert!(app.latest_autosave().is_some()); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 258ee9b..32a74a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -152,6 +152,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "file.import_audio", "file.import_midi", "session.open", + "session.save", "session.save_as", "session.save_copy", "track.group", @@ -162,6 +163,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "event.quantize", "event.transpose", "edit.strip_silence", + "clip.rename", ]; /// Shared menu hierarchy for egui and native desktop hosts. @@ -222,7 +224,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { } ui.separator(); if ui.button("Quit").clicked() { - let _ = app.run("app.quit", json!({})); + let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({})); } }); let extra = ui_aliases(); @@ -262,8 +264,9 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str } let id = command_for_path(&n.path, extra); let (enabled, checked) = item_state(app, &n.path, id.as_deref()); - let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() }; - let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or(""); + let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone()); + let label = if checked { format!("✔ {label}") } else { label }; + let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or(""); let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac())); if ui.add_enabled(enabled, btn).clicked() && let Some(id) = id @@ -394,6 +397,21 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ ("Event > MIDI Real-Time Properties", "window.rtp"), ]; +/// The shortcut of an engine or window command. +pub fn shortcut_for(id: &str) -> Option<&'static str> { + soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut) +} + +/// Undo and Redo name the step they would undo or redo; other items keep their catalog label. +pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option { + let e = &app.engine; + match id? { + "edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))), + "edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))), + _ => None, + } +} + /// Menu-style invocation: may open a dialog. pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) @@ -420,12 +438,13 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { return; } let params = params_for(path, id); - let _ = app.run(id, params); + let _ = crate::dialogs::run_or_ask(app, id, params); } -/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled. +/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its +/// dialog. Returns true when handled. pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool { - wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) + (wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({})) } /// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path. @@ -639,4 +658,24 @@ mod tests { invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix..."); assert_eq!(app.dialogs.open_name(), Some("bounce")); } + + #[test] + fn save_asks_for_a_path_until_the_session_has_one() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + invoke_menu(&mut app, "session.save", "File > Save Session"); + assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as")); + app.dialogs.open = None; + assert!(invoke_shortcut_dialog(&mut app, "session.save")); + app.dialogs.open = None; + + let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id())); + let path = dir.join("Song.scraft"); + app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap(); + app.run("track.new", json!({})).unwrap(); + assert!(!invoke_shortcut_dialog(&mut app, "session.save")); + invoke_menu(&mut app, "session.save", "File > Save Session"); + assert!(app.dialogs.open.is_none()); + assert!(!app.engine.is_dirty()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index a4882d4..e69d625 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0; pub struct MidiEditorState { pub selected: Vec, pub drag: Option, - pub top_pitch: i32, + pub scroll: f32, pub clip: Option, /// Rubber-band selection start (screen position). pub band: Option, @@ -127,28 +127,27 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); // Vertical range: centre on the notes the first time. let rows = (roll.height() / ROW_H).floor() as i32; - if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) { + if app.midi.clip != Some(cid) { let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72); let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48); - let mid = (hi + lo) / 2; - app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127); + app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H; app.midi.clip = Some(cid); } if ui.rect_contains_pointer(roll) { - let dy = ui.input(|i| i.smooth_scroll_delta.y); - if dy.abs() > 0.5 { - app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127); - } + app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y); } - let top = app.midi.top_pitch; + app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0)); + let scroll = app.midi.scroll; let px_per_tick = roll.width() / len_ticks as f32; let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick; - let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H; + let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll; + let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32; let painter = ui.painter().with_clip_rect(roll.union(keys)); // Background rows and keys. - for r in 0..=rows { + let top = pitch_at(roll.min.y); + for r in 0..=rows + 1 { let pitch = top - r; - let y = roll.min.y + r as f32 * ROW_H; + let y = y_of(pitch); let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H)); painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) }); let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0)); @@ -288,7 +287,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Audition on the instrument would go here (needs a live MIDI input path). } None => { - let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127); + let pitch = pitch_at(p.y).clamp(0, 127); let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks; let _ = app.run( "midi.note_add", diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 3ddf696..314267a 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -404,7 +404,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark); let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click()); if nresp.clicked() { - let _ = app.run("edit.select", json!({"tracks": [id.0]})); + crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video); } if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5522169..d2bee38 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -38,7 +38,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color)); ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim }); if resp.clicked() { - let _ = app.run("edit.select", json!({"tracks": [id]})); + crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true); } } }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 24e671d..4d4417b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -80,6 +80,10 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { continue; } let _ = app.run(id, json!({})); + } else if let Some(c) = + crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))) + { + let _ = app.run(c.0, json!({})); } } } @@ -108,7 +112,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("window.search", json!({})); true } - Key::Equals if m.command => { + Key::Equals if m.command && !(mac && m.ctrl) => { let _ = app.run("window.toggle_mix_edit", json!({})); true } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 1e51a2d..12b3540 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -350,7 +350,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { if is_dir { app.ui.workspace_dir = path; } else if name.ends_with(".scraft") { - let _ = app.run("session.open", json!({"path": path})); + let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path})); } else if name.ends_with(".mid") || name.ends_with(".midi") { let _ = app.run("file.import_midi", json!({"path": path})); } else { @@ -493,10 +493,13 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) { win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| { egui::ScrollArea::vertical().show(ui, |ui| { egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| { - for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) { - ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0))); - ui.label(c.label); - ui.label(egui::RichText::new(c.menu.join(" › ")).small()); + let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › "))); + let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › "))); + for (shortcut, label, menu) in engine.chain(windows) { + let Some(shortcut) = shortcut else { continue }; + ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0))); + ui.label(label); + ui.label(egui::RichText::new(menu).small()); ui.end_row(); } }); diff --git a/crates/ui-egui/tests/edit_menu.rs b/crates/ui-egui/tests/edit_menu.rs new file mode 100644 index 0000000..8fdbb52 --- /dev/null +++ b/crates/ui-egui/tests/edit_menu.rs @@ -0,0 +1,15 @@ +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +#[test] +fn undo_and_redo_name_the_step_they_would_take() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let label = |app: &SoundApp, id| menus::live_label(app, Some(id)); + assert_eq!(label(&app, "edit.undo").as_deref(), Some("Can't Undo")); + app.run("mix.mute", json!({"track": "Kick"})).unwrap(); + assert_eq!(label(&app, "edit.undo").as_deref(), Some("Undo Mute")); + assert_eq!(label(&app, "edit.redo").as_deref(), Some("Can't Redo")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(label(&app, "edit.redo").as_deref(), Some("Redo Mute")); + assert_eq!(label(&app, "edit.copy"), None); +} diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/idle.rs new file mode 100644 index 0000000..fcdc6f4 --- /dev/null +++ b/crates/ui-egui/tests/idle.rs @@ -0,0 +1,28 @@ +use soundcraft_ui_egui::{Services, SoundApp}; +use std::time::Duration; + +fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + if shown { + app.ui(ui); + } + }); + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay +} + +#[test] +fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + for _ in 0..3 { + pass(&ctx, &mut app, true); + } + assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO); + app.run("transport.play", serde_json::json!({})).unwrap(); + // Playing repaints at the meter rate (30 Hz in the Edit window, #207). + assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34)); + pass(&ctx, &mut app, false); + assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3); +} diff --git a/crates/ui-egui/tests/rename_dialog.rs b/crates/ui-egui/tests/rename_dialog.rs new file mode 100644 index 0000000..7b10b73 --- /dev/null +++ b/crates/ui-egui/tests/rename_dialog.rs @@ -0,0 +1,30 @@ +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +#[test] +fn typing_in_the_rename_clip_dialog_replaces_the_old_name() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].clone(); + app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap(); + menus::invoke_menu(&mut app, "clip.rename", "Clip > Rename..."); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..3 { + h.step(); + } + h.input_mut().events.push(Event::Text("Intro".into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + let session = h.state().engine.session(); + let renamed = session.tracks.iter().flat_map(|t| t.clips()).find(|c| c.id == clip.id).map(|c| c.name.clone()); + assert_eq!(renamed.as_deref(), Some("Intro")); +} diff --git a/crates/ui-egui/tests/save_changes.rs b/crates/ui-egui/tests/save_changes.rs new file mode 100644 index 0000000..e500a8d --- /dev/null +++ b/crates/ui-egui/tests/save_changes.rs @@ -0,0 +1,97 @@ +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +fn harness() -> Harness<'static, SoundApp> { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) +} + +fn steps(h: &mut Harness<'_, SoundApp>) { + for _ in 0..4 { + h.step(); + } +} + +fn click(h: &mut Harness<'_, SoundApp>, label: &str) { + h.get_by_label(label).click(); + steps(h); +} + +fn close_session(h: &mut Harness<'_, SoundApp>) { + menus::invoke_menu(h.state_mut(), "session.close", "File > Close Session"); + steps(h); +} + +fn mute_kick(h: &mut Harness<'_, SoundApp>) { + h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap(); +} + +#[test] +fn closing_a_modified_session_asks_first() { + let mut h = harness(); + let tracks = h.state().engine.session().tracks.len(); + h.state_mut().run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})).unwrap(); + close_session(&mut h); + assert!(h.state().dialogs.open.is_none(), "a selection alone is not a change"); + assert!(h.state().engine.session().tracks.is_empty()); + + let mut h = harness(); + mute_kick(&mut h); + close_session(&mut h); + assert_eq!(h.state().dialogs.open_name(), Some("save_changes")); + click(&mut h, "Cancel"); + assert!(h.state().dialogs.open.is_none()); + assert_eq!(h.state().engine.session().tracks.len(), tracks); + + close_session(&mut h); + click(&mut h, "Don't Save"); + assert!(h.state().engine.session().tracks.is_empty()); + + let mut h = harness(); + mute_kick(&mut h); + menus::invoke_menu(h.state_mut(), "app.quit", ""); + steps(&mut h); + assert!(!h.state().quit_requested); + click(&mut h, "Don't Save"); + assert!(h.state().quit_requested); +} + +#[test] +fn save_in_the_question_saves_before_closing() { + let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + + let mut h = harness(); + mute_kick(&mut h); + close_session(&mut h); + click(&mut h, "Save"); + match &mut h.state_mut().dialogs.open { + Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path), + other => panic!("expected Save As, got {other:?}"), + } + steps(&mut h); + click(&mut h, "OK"); + assert!(h.state().engine.session().tracks.is_empty()); + + h.state_mut().run("session.open", json!({"path": path})).unwrap(); + assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute); + h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap(); + close_session(&mut h); + click(&mut h, "Save"); + assert!(h.state().dialogs.open.is_none()); + assert!(h.state().engine.session().tracks.is_empty()); + h.state_mut().run("session.open", json!({"path": path})).unwrap(); + assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/trackpad.rs b/crates/ui-egui/tests/trackpad.rs new file mode 100644 index 0000000..83a4180 --- /dev/null +++ b/crates/ui-egui/tests/trackpad.rs @@ -0,0 +1,95 @@ +use egui::{Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use soundcraft_time::to_samples; +use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; +use std::sync::mpsc::{Sender, channel}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + tx: Sender, + width: f32, +} + +impl Rig { + fn new(width: f32) -> Rig { + let (tx, rx) = channel(); + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx); + let mut r = Rig { ctx: egui::Context::default(), app, tx, width }; + r.frames(6); + r + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() }; + self.app.raw_input_hook(&mut raw); + self.ctx + .run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }) + .textures_delta + .clear(); + } + } + + fn send(&mut self, method: &str, params: Value) { + let (req, reply) = ControlRequest::new(method, params); + self.tx.send(req).unwrap(); + self.frames(40); + assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}"); + } + + fn view(&self) -> (f64, i64) { + let z = self.app.engine.session().edit.zoom; + (z.samples_per_px, z.scroll) + } + + fn timeline(&self) -> Rect { + let [x0, y0, x1, y1] = self.app.edit_layout.timeline; + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) + } +} + +#[test] +fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() { + let mut r = Rig::new(1400.0); + let (tl, (spp, scroll)) = (r.timeline(), r.view()); + let (x, y) = (tl.center().x, tl.center().y); + assert!(r.app.edit_layout.content_h - tl.height() > 60.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0})); + assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, 0.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0})); + assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, 60.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1})); + assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y); + r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0})); + assert_eq!(r.view(), (spp, 0)); + r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap(); + let scroll_y = r.app.edit_layout.scroll_y; + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24})); + assert_eq!(r.view().1, 3); + assert_eq!(r.app.edit_layout.scroll_y, scroll_y); +} + +#[test] +fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() { + let mut r = Rig::new(1400.0); + let tl = r.timeline(); + let (x, y) = (tl.min.x + 300.0, tl.center().y); + let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0; + let (spp, at) = (r.view().0, under(&r)); + r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0})); + assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0); + assert!((under(&r) - at).abs() < 8.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true})); + assert!(r.view().0 < spp / 4.0); + assert!((under(&r) - at).abs() < 8.0); + let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true})); + assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, scroll_y); +} diff --git a/crates/ui-egui/tests/window_shortcuts.rs b/crates/ui-egui/tests/window_shortcuts.rs new file mode 100644 index 0000000..11e591e --- /dev/null +++ b/crates/ui-egui/tests/window_shortcuts.rs @@ -0,0 +1,43 @@ +use egui::os::OperatingSystem; +use egui::{Key, Modifiers}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts}; + +const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn window_shortcuts_open_and_close_windows() { + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, cmd) in [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(os); + for key in [Key::Num1, Key::Num3, Key::Num4, Key::Num5, Key::Num9] { + press(&ctx, &mut app, key, cmd); + } + press(&ctx, &mut app, Key::M, Modifiers { alt: true, ..cmd }); + let ui = &app.ui; + assert!( + ui.show_transport && ui.show_big_counter && ui.show_automation && ui.show_memory_locations && ui.show_video && ui.narrow_mix, + "{os:?}" + ); + press(&ctx, &mut app, Key::W, cmd); + assert!(!app.ui.show_transport && !app.ui.show_big_counter && !app.ui.show_memory_locations, "{os:?}"); + } +} + +#[test] +fn cmd_ctrl_equals_opens_the_score_editor_on_a_mac() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(OperatingSystem::Mac); + press(&ctx, &mut app, Key::Equals, Modifiers { ctrl: true, ..MAC_CMD }); + assert!(app.ui.show_score); + assert_eq!(app.ui.window, MainWindow::Edit); + press(&ctx, &mut app, Key::Equals, MAC_CMD); + assert_eq!(app.ui.window, MainWindow::Mix); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 32a4932..95a5bbf 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,13 +42,15 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr | `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. | | `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). | | `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. | -| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. | +| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. | | `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). | | `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. | | `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). | | `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). | | `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). | | `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. | +| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. | +| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). | | `ui.move` | `{x, y}` | Move the pointer. | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). | | `ui.text` | `{text}` | Type text into the focused field. |