Merge pull request #70 from grovebit/export-clips-shortcut

Shift+Cmd+K exports clips as files
This commit is contained in:
Brandon Thomas 2026-10-11 20:40:48 -04:00 committed by GitHub
commit 3e97e49bfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 54 additions and 10 deletions

View File

@ -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())
})),
}
}

View File

@ -113,11 +113,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
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};

View File

@ -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 {

View File

@ -162,6 +162,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
pub pick_folder: Option<Box<dyn Fn(&str) -> Option<String>>>,
}
/// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`).

View File

@ -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"));
}
}