diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 8004acf..7e3b989 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -157,6 +157,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 dc66dea..805acb0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -113,11 +113,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?, replace?: false}", has_selection, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string(); @@ -501,6 +504,20 @@ mod tests { 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"); + } + fn midi_engine() -> Engine { use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; use soundcraft_model::{ChannelFormat, Clip, TrackKind}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d49b2af..045e11d 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() @@ -420,8 +428,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 09943d6..8103a09 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,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 fb978c3..24e671d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -75,10 +75,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; } @@ -87,6 +84,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; @@ -284,8 +288,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 }; @@ -315,4 +319,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")); + } }