From 9ed4bdd4b152deaf1e28afeba04de3313d9b0766 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:45:22 +0100 Subject: [PATCH] Shift+Cmd+K exports clips as files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export Clips as Files had no shortcut and no menu entry, so Shift+Cmd+K, its key in Pro Tools, did nothing (#7). It now opens the command's folder prompt, and OK exports the selected clips there. Trying it showed three more gaps: - Browse… in a folder prompt opened an audio-file picker, so no folder could be chosen. Folder prompts (Export Clips, Bounce Stems) now use a folder picker, a new `pick_folder` host service; the web build has none and keeps the text field. - A successful export said nothing. The status bar now reads "Exported N clip(s) to ", and the command returns the folder. - With nothing selected the export succeeded and wrote no files. It now fails with "no clips selected". The shortcut lookup moves into `command_for` so a test can check what a key resolves to. --- apps/soundcraft/src/main.rs | 3 +++ crates/engine/src/cmd/file.rs | 21 +++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/shortcuts.rs | 23 +++++++++++++++++------ 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..91074ec 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -165,6 +165,9 @@ fn services() -> Services { } d.save_file().map(|p| p.to_string_lossy().into_owned()) })), + pick_folder: Some(Box::new(|start: &str| { + rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned()) + })), } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..cc823c2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -109,11 +109,14 @@ pub fn specs() -> Vec { } Ok(json!({"text": text})) }), - cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| { + cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| { let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string(); let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("file.export_clips", "no clips selected")); + } let n = crate::io::export_clips(e, &ids, &dir)?; - Ok(json!({"written": n})) + Ok(json!({"written": n, "dir": dir})) }), cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string(); @@ -428,4 +431,18 @@ mod tests { assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn exports_the_selected_clips_and_refuses_an_empty_selection() { + let mut e = crate::demo::demo_engine(); + let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id())); + let p = json!({"dir": dir.to_string_lossy()}); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(e.execute("file.export_clips", &p).is_err()); + e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap(); + let r = e.execute("file.export_clips", &p).unwrap(); + let files = std::fs::read_dir(&dir).map_or(0, Iterator::count); + let _ = std::fs::remove_dir_all(&dir); + assert!(files >= 1 && r["written"] == files, "{r}, {files} files"); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..846b3c3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -291,11 +291,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if let Some(pick) = &app.services.pick_open && cmd != "session.save_as" && cmd != "session.save_copy" + && key != "dir" && ui.button("Browse…").clicked() && let Some(p) = pick(cmd, &[]) { *path = p; } + if let Some(pick) = &app.services.pick_folder + && key == "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(path) + { + *path = p; + } if let Some(pick) = &app.services.pick_save && (cmd == "session.save_as" || cmd == "session.save_copy") && ui.button("Browse…").clicked() @@ -412,8 +420,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } if let Some((cmd, params)) = action { keep = false; - if let Err(e) = app.run(&cmd, params) { - app.ui.status = e; + match app.run(&cmd, params) { + Ok(v) if cmd == "file.export_clips" => { + app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default()); + } + Ok(_) => {} + Err(e) => app.ui.status = e, } } if keep { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6da9b92..c77a088 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,7 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + pub pick_folder: Option Option>>, } /// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`). diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..981a1b7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -73,10 +73,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { if fixed(app, key, mods, mac) { continue; } - let hit = - soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))); - if let Some(c) = hit { - let id = c.id; + if let Some(id) = command_for(key, mods, mac) { if crate::menus::invoke_shortcut_dialog(app, id) { continue; } @@ -85,6 +82,13 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { } } +fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { + soundcraft_engine::command_specs() + .iter() + .find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))) + .map(|c| c.id) +} + /// Keys with context-dependent meaning. Returns true when handled. fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let plain = !m.command && !m.alt && !m.ctrl && !m.shift; @@ -282,8 +286,8 @@ mod label_tests { #[cfg(test)] mod tests { - use super::{mods_match, parse, search_chord}; - use egui::Modifiers; + use super::{command_for, mods_match, parse, search_chord}; + use egui::{Key, Modifiers}; /// Ctrl held on Windows or Linux, as egui reports it. const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; @@ -313,4 +317,11 @@ mod tests { assert!(!search_chord(MAC_CMD, true)); assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true)); } + + #[test] + fn shift_cmd_k_prompts_for_a_folder_to_export_clips() { + assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips")); + assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips")); + assert!(crate::dialogs::takes_path("file.export_clips")); + } }