From 718bb5c7fbfd422dab97e67e93561cdf12de546b Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:27:39 +0100 Subject: [PATCH 01/15] Open Save As when saving a session that has never been saved Cmd+S and File > Save Session on an untitled session only left "no path yet: pass `path` (Save As)" in the status line. From a menu, the command palette or the keyboard, Save now opens the Save Session As prompt until the session has a file. Calls from the control channel, MCP and the CLI still need a `path`. The prompt suggests ~//.scraft, but saving a session without audio there failed with "No such file or directory", because only writing audio created the session folder. Saving now creates the folder. --- crates/engine/src/cmd/file.rs | 10 ++++++++++ crates/engine/src/io.rs | 1 + crates/ui-egui/src/dialogs.rs | 5 +++-- crates/ui-egui/src/menus.rs | 21 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..ca7393d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -391,6 +391,16 @@ mod tests { 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/io.rs b/crates/engine/src/io.rs index cb3fc18..dd9a04a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -519,6 +519,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..e2faaee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -72,8 +72,9 @@ 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"), key: "path".into(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89aca57..21d4c6e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -149,6 +149,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "file.import_audio", "file.import_midi", "session.open", + "session.save", "session.save_as", "session.save_copy", "track.group", @@ -618,4 +619,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); + } } From 6964c50d004dcae44814e7e248ef23be85642d31 Mon Sep 17 00:00:00 2001 From: natocool2 Date: Sat, 10 Oct 2026 15:52:38 +0100 Subject: [PATCH 02/15] Web: open, import, save and export files in the browser (fixes #63, fixes #64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build could not reach any file: File > Open Session…, Import > Audio… and Import > MIDI… showed a text box for a path the browser can't read, and every save or export failed with «operation not supported on this platform», so work was lost on close. On the web now: - Open Session…, Import Audio… and Import MIDI… show the browser's file picker; files dropped on the page open (sessions) or import (audio, MIDI) by extension. Files travel as bytes; imports are one undo step each, like the import commands. - A session is saved as ONE file, a bundle `.scraftz`: a zip (stored entries) holding the `.scraft` and its `Audio Files/` as 32-bit float WAV — the same layout as a session folder, since the browser has no folders. `io::open_session_bytes` opens a bundle or a bare `.scraft` (its audio is then reported missing). Bundles are hostile input: entry count and size are capped, and declared sizes are never read past. - Every single-file output (save, Save As Template, bounce, MIDI/score/text export, track presets) goes through `io::write_file`: a file natively, a download in the browser via a delivery hook (`io::set_deliver`) that only the web app installs. - In the browser Save As asks for the bundle's name (`.scraftz`) instead of a folder path. Native builds keep writing files and session folders as before. Folder outputs (stems, clip exports) are still not available on the web. Pairs with #157 (Save on a never-saved session opens Save As), which the browser needs too: the web app always starts on the unsaved demo. New dependency: `zip` (MIT), default features off (stored entries only). Checked: engine tests for the bundle round trip (every sample identical), same-named sources, bare sessions and hostile bundles; `cargo xtask ci`. In Chromium on the packaged web build: ⌘⇧I picks and imports a WAV; WAV, MP3, FLAC, OGG, M4A, AIFF and MIDI import by drop; Save As → `Midnight Groove.scraftz` downloads with the imported audio inside; that bundle reopens in a fresh page with all tracks and no missing media, and saves again with the same content. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 36 ++++ Cargo.toml | 2 + apps/soundcraft-web/Cargo.toml | 6 +- apps/soundcraft-web/src/main.rs | 5 +- apps/soundcraft-web/src/web.rs | 181 ++++++++++++++++++- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file.rs | 10 +- crates/engine/src/cmd/track_more.rs | 2 +- crates/engine/src/io.rs | 258 +++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 7 +- 10 files changed, 484 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b26758..7428447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2133,6 +2133,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" @@ -3502,6 +3514,7 @@ dependencies = [ "soundcraft-video", "soundcraft-vst3-host", "thiserror 2.0.21", + "zip", ] [[package]] @@ -3623,8 +3636,12 @@ version = "0.4.0" dependencies = [ "eframe", "egui", + "js-sys", "log", + "rfd", + "serde_json", "soundcraft-engine", + "soundcraft-model", "soundcraft-playback", "soundcraft-ui-egui", "wasm-bindgen", @@ -4084,6 +4101,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" @@ -5374,6 +5397,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 95b9a8e..bb61412 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/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/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/file.rs b/crates/engine/src/cmd/file.rs index d58af41..4e26b85 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { s.sources.clear(); s.videos.clear(); let text = s.to_json().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})) }), cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { @@ -99,13 +99,13 @@ pub fn specs() -> Vec { cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); let bytes = crate::io::export_midi(e)?; - 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})) }), @@ -261,7 +261,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; - 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}), ) @@ -280,7 +280,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})) } 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/io.rs b/crates/engine/src/io.rs index cb3fc18..8c774d8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -456,7 +456,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; @@ -535,16 +541,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()), } } @@ -557,6 +558,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 = 4 << 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)) +} + /// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record"). /// `channels` is the planar input capture at `rate`; each track takes the input channels its /// input route names ("In 3" → channel 2), defaulting to the first channels. @@ -638,3 +806,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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..add16b9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -75,7 +75,12 @@ impl Dialogs { "session.save_as" | "session.save_copy" => Dialog::PathPrompt { cmd: id.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() }, From aa4f038f1a3a624c92b82e62fb942babe5cde1aa Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:01:33 +0100 Subject: [PATCH 03/15] Open the Rename Clip dialog from Clip > Rename Clip > Rename... and Cmd+Alt+Shift+R ran clip.rename without a name, so they only put "`name` required" in the status line. They now open the Rename Clip dialog, which only the clip's right-click menu opened so far, for the selected clip (or the first clip the edit selection touches). The dialog's name field, and Rename Track's, now opens with the old name selected, so typing replaces it instead of adding to it. --- crates/ui-egui/src/dialogs.rs | 34 +++++++++++++++++++++++---- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/tests/rename_dialog.rs | 30 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/rename_dialog.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..5848673 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -100,6 +100,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, @@ -273,15 +278,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}))); } @@ -436,6 +439,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})) @@ -515,6 +529,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/menus.rs b/crates/ui-egui/src/menus.rs index 89aca57..0e540e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -159,6 +159,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "event.quantize", "event.transpose", "edit.strip_silence", + "clip.rename", ]; /// Shared menu hierarchy for egui and native desktop hosts. 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")); +} From bd00c3855ccca4d73d8a0620ea1296279f57743e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:03:28 +0100 Subject: [PATCH 04/15] Name the step in the Edit menu's Undo and Redo The Edit menu always read "Can't Undo" and "Can't Redo", the incumbent's labels for an empty history, even when there was something to undo. The items now name the step they would take ("Undo Mute", "Redo Mute"), in the in-window menu bar and the macOS system menu alike, and read "Can't Undo" / "Can't Redo" only when there is nothing. --- apps/soundcraft/src/native_menu.rs | 5 +++++ crates/ui-egui/src/menus.rs | 13 ++++++++++++- crates/ui-egui/tests/edit_menu.rs | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/edit_menu.rs diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index 9cc72de..82cd25a 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -105,6 +105,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/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e540e0..5b8f079 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -259,7 +259,8 @@ 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 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(soundcraft_engine::find_command).and_then(|c| c.shortcut).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() @@ -387,6 +388,16 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ ("Event > MIDI Real-Time Properties", "window.rtp"), ]; +/// 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) 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); +} From 6b98c21fedac5063a5676aa472df4ae754eb714c Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:20:55 +0100 Subject: [PATCH 05/15] Make the Window menu's shortcuts work The Window menu's keys never fired: Cmd+1 Transport, Cmd+3 Big Counter, Cmd+4 Automation, Cmd+5 Memory Locations, Cmd+9 Video, Cmd+Alt+M Narrow Mix, Cmd+W Close Window, Cmd+Ctrl+W Hide All Floating Windows and Cmd+Ctrl+= Score Editor. The shortcut handler only looked at engine commands, and these are window commands. It now falls back to them, and the menus and the Keyboard Shortcuts window show their keys. On a Mac, Cmd+Ctrl+= toggled Mix/Edit instead of opening the Score Editor. The Mix/Edit toggle now needs Cmd+= without Control there. The two Control chords still have no key on Windows and Linux. --- crates/ui-egui/src/menus.rs | 7 +++- crates/ui-egui/src/shortcuts.rs | 6 +++- crates/ui-egui/src/windows.rs | 11 +++--- crates/ui-egui/tests/window_shortcuts.rs | 43 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/window_shortcuts.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b8f079..bbee052 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -261,7 +261,7 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str let (enabled, checked) = item_state(app, &n.path, id.as_deref()); 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(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or(""); + 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 @@ -388,6 +388,11 @@ 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; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..8438d3a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -81,6 +81,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!({})); } } } @@ -102,7 +106,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 b7b34e5..b4145e9 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -488,10 +488,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/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); +} From 992f60c79c6d6ab6c64624b1ad99d23605d989b7 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:21:46 +0100 Subject: [PATCH 06/15] Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom - Pinch and Cmd+scroll zoom the timeline around the pointer. egui 0.36 turns both into zoom_delta, so the old Cmd+scroll branch never ran; Shift+wheel pans sideways again for the same reason. - Two-finger panning keeps the sub-sample remainder, so slow motion still moves at the closest zoom; the scrollbar drag pans the same way (its range now takes in the view, so the thumb stays in the bar), and the rulers pan too. Panning stays open-ended to the right, as before. - A swipe keeps to the axis it starts along, so panning doesn't drift between tracks and scrolling doesn't drift in time. The larger share of its first 6 points decides until egui reports the scroll over: the fingers lift, or 150 ms pass without wheel events. - The MIDI editor scrolls by the point instead of rounding each frame's delta to whole rows (slow swipes did nothing), and its range fits the panel (pitch 0 could not be reached; a taller panel showed blank rows). - Zoom and scroll write through `Engine::zoom_mut` (`view` commands): no playback resync (a session copy sent to the audio thread every frame), no copy of the whole session per frame, no journal entry per frame, and no end to an undo gesture (scrolling during an automation pass made each write its own undo step). New `view.zoom_by {factor, x?}`; `view.scroll` saturates; Fill Window and Zoom to Selection stay within the zoom range (zooming out after them on a long session zoomed in). - Control channel: `ui.scroll` (trackpad swipe or wheel notches) and `ui.zoom` (pinch); `session.inspect` reports zoom. --- README.md | 3 +- crates/engine/src/cmd/mod.rs | 18 ++-- crates/engine/src/cmd/plugin_state.rs | 1 + crates/engine/src/cmd/view.rs | 139 ++++++++++++++++++-------- crates/engine/src/inspect.rs | 1 + crates/engine/src/lib.rs | 33 ++++-- crates/engine/tests/hostile_params.rs | 3 + crates/ui-egui/src/control.rs | 31 +++++- crates/ui-egui/src/edit_window.rs | 60 +++++++---- crates/ui-egui/src/midi_editor.rs | 25 +++-- crates/ui-egui/tests/trackpad.rs | 95 ++++++++++++++++++ docs/control-protocol.md | 4 +- 12 files changed, 318 insertions(+), 95 deletions(-) create mode 100644 crates/ui-egui/tests/trackpad.rs diff --git a/README.md b/README.md index 89ec40e..f98c1ff 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/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b72971..3a69106 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -51,6 +51,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. @@ -80,18 +82,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/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/lib.rs b/crates/engine/src/lib.rs index f12901d..c9c0fd4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,7 +16,7 @@ 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::sync::Arc; @@ -108,7 +108,7 @@ 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, /// Gesture key for undo coalescing (see `execute_merged`). @@ -158,6 +158,12 @@ 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() }; @@ -227,7 +233,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 +242,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 +253,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 +281,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 { @@ -329,6 +346,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::*; 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/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/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..cb1f4e0 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -44,6 +44,11 @@ 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, } pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { @@ -195,23 +200,7 @@ fn main_area(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 = @@ -262,12 +251,45 @@ fn folder_collapsed(s: &Session, t: &Track) -> bool { t.folder.and_then(|f| s.track(f)).is_some_and(|f| !f.folder_open) } +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_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); + } +} + +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)); @@ -276,7 +298,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); } } 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/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/docs/control-protocol.md b/docs/control-protocol.md index a64c4c8..548b54a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,13 +30,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. | From d7c38188596d866b03c9d07fd532df145dc85301 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:31:26 +0100 Subject: [PATCH 07/15] Let the desktop app sleep when idle or covered The app sent the window title to egui on every frame. Each viewport command asks for another frame, so the window redrew at the display's refresh rate with nothing happening: 35-43% of a CPU core and 13-20% of the GPU per window on a 120 Hz Mac. While the window was covered or minimized, eframe runs only App::logic, with no vsync to pace it, and the same request kept one core at 100%. The title is now sent only when it changes. The last title sent is kept in egui's context data rather than compared with the window's, because winit can't read a title back on X11 or Wayland. Some work only ran because another frame always came, so an idle window still wakes once a second, and every 30 ms while a plugin editor is open. Autosave and the macOS menu bar's throttled refresh run on frames, and plugin editors are native windows egui never hears from: their edits (VST3 performEdit, CLAP output events) and main-thread requests are serviced on frames. egui takes its predicted frame time off every delay, so it is added back. The menu bar can now trail a shortcut by up to a second instead of 250 ms. Playback, falling meters, pending screenshots and scripted input ask for the next frame from ui() instead of logic(). eframe skips ui() for a covered or minimized window, so a hidden window runs on the heartbeat instead of spinning, and egui redraws it as soon as it shows again. Autosave was timed by egui's frame clock, which stands still while the window is hidden, so a modified session wasn't autosaved until the window showed again. It is timed by the wall clock now. Only the desktop app sets an autosave folder, so the web build never reads it. Tests check that an unchanged title sends no command, that only a shown window animates while a hidden one wakes once a second, and that a hidden window still autosaves. Measured on a 120 Hz MacBook with a release build, the demo session and audio off: an idle Edit window went from 35-43% CPU and 13-20% GPU to 1.5% and 0.4% (the insertion point blinks), the Mix window to 0.2%, and a covered window, idle or playing, from ~100% CPU to 0%. --- apps/soundcraft/src/main.rs | 29 ++++++++++++++++++++++++- crates/playback/src/lib.rs | 4 ++++ crates/ui-egui/src/lib.rs | 42 +++++++++++++++++++++++++----------- crates/ui-egui/tests/idle.rs | 27 +++++++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/idle.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a54e7e1..27684a1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -25,6 +25,15 @@ 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. @@ -61,7 +70,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); @@ -267,3 +276,21 @@ fn main() -> eframe::Result { }), ) } + +#[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/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..0e0a380 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -584,6 +584,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/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..1e28b04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -216,7 +216,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, @@ -276,7 +276,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(), @@ -473,15 +473,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")); @@ -798,16 +797,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.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !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); } @@ -873,6 +873,10 @@ 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.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + ctx.request_repaint(); + } } /// UI state as JSON (control channel `ui.inspect`). @@ -960,6 +964,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/tests/idle.rs b/crates/ui-egui/tests/idle.rs new file mode 100644 index 0000000..be7cfa7 --- /dev/null +++ b/crates/ui-egui/tests/idle.rs @@ -0,0 +1,27 @@ +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(); + assert_eq!(pass(&ctx, &mut app, true), Duration::ZERO); + pass(&ctx, &mut app, false); + assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3); +} From 18a25581048e4b721e467b3152cbcdbdd9a29cff Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:04 +0100 Subject: [PATCH 08/15] Ask to save changes before closing a modified session Close Session, New, Open, Revert, Quit, the window's close button, dropping a session file on the window and opening one from the Workspace window all replaced or closed the session without asking, so unsaved edits were lost (#136). They now ask first: Save, Don't Save or Cancel. Save on a session that has never been saved goes through Save As, then carries on. Revert asks for confirmation instead, and Revert to Saved is unavailable until the session has a file. A take being recorded is stopped first, so it counts as a change. The web build has nowhere to save, so it offers only Don't Save and Cancel. Calls from the control channel, MCP and the CLI never ask. Selecting, locating, zooming and scrolling no longer mark the session modified, so the question only comes after real edits. Undoing back to the saved state makes the session clean again. The engine compares the document with the one last opened or saved, ignoring the selection, the playhead and the zoom, once per revision. Fixes #136 --- apps/soundcraft/src/main.rs | 6 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/lib.rs | 62 +++++++++++++++++- crates/ui-egui/src/dialogs.rs | 84 ++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 9 +-- crates/ui-egui/src/windows.rs | 2 +- crates/ui-egui/tests/save_changes.rs | 97 ++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/save_changes.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..5d9385c 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -19,7 +19,7 @@ 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); @@ -47,6 +47,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); } // With the system menu bar there is no egui bar showing the window and dirty state, so the // title carries them; elsewhere the egui menu bar already does. @@ -83,7 +85,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 { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ca7393d..104580b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok(json!({"path": path})) }), - 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!({})) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f12901d..d186c20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod score; use serde_json::{Value, json}; use soundcraft_model::{Clip, Session, TrackId}; use soundcraft_time::Samples; +use std::cell::Cell; use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; @@ -111,6 +112,10 @@ pub struct Engine { /// Revision counter; bumps on every document change (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(), } @@ -164,17 +172,30 @@ impl Engine { 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 { @@ -318,6 +339,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(|| { @@ -346,3 +380,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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e2faaee..e15407c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,11 +23,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 { @@ -46,6 +49,7 @@ impl Dialogs { Dialog::Group { .. } => "group", Dialog::Session { .. } => "session", Dialog::ScoreSetup { .. } => "score_setup", + Dialog::SaveChanges { .. } => "save_changes", }) } @@ -59,6 +63,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 { @@ -110,6 +115,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 { @@ -164,6 +195,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", @@ -177,6 +210,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)); @@ -405,18 +440,67 @@ 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; if let Err(e) = app.run(&cmd, params) { 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); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 21d4c6e..23a3e69 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -219,7 +219,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(); @@ -413,12 +413,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. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b7b34e5..007809a 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 { 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); +} From 5f122fb30dc87515150e7e5cf4d06414ed64743e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:21:04 +0100 Subject: [PATCH 09/15] Fix drag selection and the insertion point in the Edit window Fixes #166: dragging a selection up from the last track deselected it and selected every track above instead. Fixed, along with related Edit-window drag problems: - Drag selection spans exactly the tracks between the press and the pointer, in either direction, and the whole window updates in the same frame. - Shift+click in another track adds it to the selection with the same range, as in Pro Tools, so the selected tracks no longer have to be adjacent. A click outside the range extends it. - Dropping a track's name plate lower down no longer sends the track to the end. - Drags act where you pressed: a quick drag from a clip edge now trims instead of moving the clip. - Dragging past the edge of the track area scrolls, and a flick past the last track selects down to it. - The insertion point moves on press, shows immediately, and only on the selected tracks. - edit.select no longer makes a new document revision when nothing changed (each one is resent to the audio engine). - Fixes a panic when a fade drag's clip shrinks to zero length. --- crates/engine/src/cmd/edit.rs | 45 +- crates/ui-egui/src/edit_window.rs | 788 ++++++++++++++++++++++++------ crates/ui-egui/src/lib.rs | 6 +- 3 files changed, 665 insertions(+), 174 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e4f2d5..02c8597 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -350,7 +350,8 @@ fn select(e: &mut Engine, p: &Value) -> Result { let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); 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 { @@ -364,11 +365,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 { @@ -384,17 +385,21 @@ 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 st.is_some() { - s.edit.selected_clips.clear(); + ed.selected_clips.clear(); } - 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})) + let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips}); + if ed != s.edit { + e.session_mut().edit = ed; + } + Ok(out) } fn shift_selection(e: &mut Engine, dir: i64) -> Result { @@ -750,3 +755,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/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..e95cc99 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -46,6 +46,20 @@ pub struct EditLayout { pub last_follow_to: Option, } +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 { tl.min.x + ((at - s.edit.zoom.scroll) as f64 / s.edit.zoom.samples_per_px.max(0.01)) as f32 } @@ -63,6 +77,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)); @@ -208,31 +223,31 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { 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)); + app.edit_layout.scroll_by(-delta.y, tl.height()); } } } 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| !x.hidden && !folder_collapsed(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() { @@ -253,7 +268,7 @@ fn main_area(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()); } } } @@ -632,7 +647,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)); @@ -1444,14 +1459,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 @@ -1490,12 +1507,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 @@ -1514,8 +1531,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() { @@ -1523,151 +1543,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 => { @@ -1691,16 +1609,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() @@ -1711,6 +1621,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, 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(), @@ -1840,15 +1897,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()); @@ -1906,7 +1973,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() { @@ -1946,4 +2014,402 @@ 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 shift_click(h: &mut Harness, at: Pos2) { + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + 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; + shift_click(&mut h, b); + assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]]); + assert_eq!(h.state().engine.session().edit.selection, sel); + shift_click(&mut h, c); + 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 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 fc0685d..fede065 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -731,6 +731,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 { @@ -747,8 +750,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(); From 69570e5b54a614e732f05bdfd28ced77f22916fe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:16:15 -0400 Subject: [PATCH 10/15] Cap session bundles at 1 GiB: 4 GiB is all of wasm32's address space Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 8c774d8..464740e 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -578,7 +578,7 @@ 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 = 4 << 30; +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. From deea994688838e0f0db9a9f3073e99b06d87de7f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:22:24 -0400 Subject: [PATCH 11/15] Grid moves snap the grabbed clip, not whatever is under the pointer now Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 9f9c972..35d8270 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1660,7 +1660,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { 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, at)).map_or(*grab_at, |c| c.start); + 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 From 157e2a9547e945f5fe8093e4088efefcebf1d279 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:21:04 +0100 Subject: [PATCH 12/15] Fix drag selection and the insertion point in the Edit window Fixes #166: dragging a selection up from the last track deselected it and selected every track above instead. Fixed, along with related Edit-window drag problems: - Drag selection spans exactly the tracks between the press and the pointer, in either direction, and the whole window updates in the same frame. - Shift+click in another track adds it to the selection with the same range, as in Pro Tools, so the selected tracks no longer have to be adjacent. A click outside the range extends it. - Track names follow Pro Tools' rules in the Edit window, the Mix window and the Tracks list: Shift+click selects every track between the nearest selected one and the clicked one, and Cmd+click (Ctrl+click on Windows and Linux) adds or removes one. A click on a name used to replace the selection; only the Edit header's background added, and it treated Shift like Cmd. - Dropping a track's name plate lower down no longer sends the track to the end. - Drags act where you pressed: a quick drag from a clip edge now trims instead of moving the clip. - Dragging past the edge of the track area scrolls, and a flick past the last track selects down to it. - The insertion point moves on press, shows immediately, and only on the selected tracks. - edit.select no longer makes a new document revision when nothing changed (each one is resent to the audio engine). - Fixes a panic when a fade drag's clip shrinks to zero length. --- crates/engine/src/cmd/edit.rs | 45 +- crates/ui-egui/src/edit_window.rs | 842 ++++++++++++++++++++++++------ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/mix_window.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- 5 files changed, 709 insertions(+), 188 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e4f2d5..02c8597 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -350,7 +350,8 @@ fn select(e: &mut Engine, p: &Value) -> Result { let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); 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 { @@ -364,11 +365,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 { @@ -384,17 +385,21 @@ 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 st.is_some() { - s.edit.selected_clips.clear(); + ed.selected_clips.clear(); } - 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})) + let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips}); + if ed != s.edit { + e.session_mut().edit = ed; + } + Ok(out) } fn shift_selection(e: &mut Engine, dir: i64) -> Result { @@ -750,3 +755,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/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..38698c3 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -46,6 +46,20 @@ pub struct EditLayout { pub last_follow_to: Option, } +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 { tl.min.x + ((at - s.edit.zoom.scroll) as f64 / s.edit.zoom.samples_per_px.max(0.01)) as f32 } @@ -63,6 +77,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)); @@ -208,31 +223,31 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { 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)); + app.edit_layout.scroll_by(-delta.y, tl.height()); } } } 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() { @@ -253,13 +268,38 @@ fn main_area(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 hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { @@ -632,7 +672,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)); @@ -736,15 +776,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. @@ -767,7 +799,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() @@ -1444,14 +1476,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 @@ -1490,12 +1524,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 @@ -1514,8 +1548,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() { @@ -1523,151 +1560,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 => { @@ -1691,16 +1626,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() @@ -1711,6 +1638,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, 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(), @@ -1840,15 +1914,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()); @@ -1906,7 +1990,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() { @@ -1946,4 +2031,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 fc0685d..fede065 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -731,6 +731,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 { @@ -747,8 +750,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(); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..d3e5afa 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -391,7 +391,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 33c4324..667aa31 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -37,7 +37,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); } } }); From b01abf6dd72955b04ca63c0634b203ed47410c82 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:43:27 -0400 Subject: [PATCH 13/15] rustfmt the mix.send spec Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mix.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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) From f855836af103eab9b83c02ce9f7c5f49321b3c84 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:51:00 -0400 Subject: [PATCH 14/15] Idle test: playing repaints at the adaptive meter rate from #207 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/idle.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/idle.rs index be7cfa7..fcdc6f4 100644 --- a/crates/ui-egui/tests/idle.rs +++ b/crates/ui-egui/tests/idle.rs @@ -21,7 +21,8 @@ fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() { } assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO); app.run("transport.play", serde_json::json!({})).unwrap(); - assert_eq!(pass(&ctx, &mut app, true), Duration::ZERO); + // 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); } From 2ec269833114dc11aa3936bff20d2ed6da2b02b6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:56:37 -0400 Subject: [PATCH 15/15] Grid moves snap the grabbed clip, not whatever is under the pointer now Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 38698c3..1b5163b 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1684,7 +1684,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { 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, at)).map_or(*grab_at, |c| c.start); + 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