From eb5bf699c7565c18a56a2bc5b802662b4345b6ec Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:13:25 +0100 Subject: [PATCH 1/2] Bounce Mix: Browse for the file, and start in the session folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From #186: the Bounce dialog had no Browse button, so the file path had to be typed, and it always proposed the home folder. - A Browse… button opens the system save panel with an Audio filter (WAV, AIFF, FLAC). The chosen file goes in the field, and its extension sets the Format. - A saved session bounces into its own folder by default; unsaved sessions still start in the home folder. - The desktop save panel now picks its filter by purpose (bounce or session) and opens in the proposed file's folder when it exists, so Save As starts there too instead of wherever the panel was last. - The file field is 420pt wide, like the other path prompts. --- apps/soundcraft/src/main.rs | 10 ++++++-- crates/ui-egui/src/dialogs.rs | 17 +++++++++++-- crates/ui-egui/tests/bounce_dialog.rs | 36 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/bounce_dialog.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..ac867b7 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -167,9 +167,15 @@ fn services() -> Services { }; d.pick_file().map(|p| p.to_string_lossy().into_owned()) })), - pick_save: Some(Box::new(|_purpose: &str, default: &str| { + pick_save: Some(Box::new(|purpose: &str, default: &str| { let path = std::path::Path::new(default); - let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]); + let mut d = match purpose { + "file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]), + _ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]), + }; + if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { + d = d.set_directory(dir); + } if let Some(n) = path.file_name().and_then(|n| n.to_str()) { d = d.set_file_name(n); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..38eaa21 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -85,7 +85,8 @@ impl Dialogs { } "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { - Dialog::Bounce { path: format!("{home}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } + let dir = e.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map_or(home.clone(), |d| d.display().to_string()); + Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } } "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() }, @@ -339,7 +340,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { path, format, bit_depth, normalize } => { ui.horizontal(|ui| { ui.label("File"); - ui.add(egui::TextEdit::singleline(path).desired_width(360.0)); + ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); + if let Some(pick) = &app.services.pick_save + && ui.button("Browse…").clicked() + && let Some(p) = pick("file.bounce_mix", path) + { + *path = p; + match std::path::Path::new(path.as_str()).extension().map(|x| x.to_string_lossy().to_lowercase()).as_deref() { + Some("wav") => *format = "wav".into(), + Some("aif" | "aiff") => *format = "aiff".into(), + Some("flac") => *format = "flac".into(), + _ => {} + } + } }); ui.horizontal(|ui| { egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| { diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/bounce_dialog.rs new file mode 100644 index 0000000..04ad4d6 --- /dev/null +++ b/crates/ui-egui/tests/bounce_dialog.rs @@ -0,0 +1,36 @@ +//! Bounce Mix starts in the session's folder and has a Browse button for the file (#186). +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog}; +use std::cell::RefCell; +use std::rc::Rc; + +#[test] +fn bounce_starts_in_the_session_folder_and_browse_picks_the_file_and_format() { + let asked = Rc::new(RefCell::new(Vec::new())); + let log = Rc::clone(&asked); + let pick_save: Box Option> = Box::new(move |purpose, default| { + log.borrow_mut().push((purpose.to_string(), default.to_string())); + Some("/Mixes/Final Mix.FLAC".into()) + }); + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.path = Some("/Songs/Midnight Groove/Midnight Groove.scraft".into()); + let mut app = SoundApp::new(engine, None, Services { pick_save: Some(pick_save), ..Default::default() }); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Browse…").click(); + h.run_steps(3); + let default = "/Songs/Midnight Groove/Midnight Groove Bounce.wav".to_string(); + assert_eq!(*asked.borrow(), vec![("file.bounce_mix".to_string(), default)]); + match &h.state().dialogs.open { + Some(Dialog::Bounce { path, format, .. }) => assert_eq!((path.as_str(), format.as_str()), ("/Mixes/Final Mix.FLAC", "flac")), + other => panic!("{other:?}"), + } +} From 285d9fecd52595f44b96665660bdadb8315e6829 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:31:47 -0400 Subject: [PATCH 2/2] Bounce Mix: a session path without a folder starts in the default one A relative session path has an empty parent, which made the default "/ Bounce.wav". Ignore an empty parent so it falls back to the default folder, as for a session never saved. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 7 ++++++- crates/ui-egui/tests/bounce_dialog.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38eaa21..67f23d4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -85,7 +85,12 @@ impl Dialogs { } "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { - let dir = e.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map_or(home.clone(), |d| d.display().to_string()); + let dir = e + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).parent()) + .filter(|d| !d.as_os_str().is_empty()) + .map_or(home.clone(), |d| d.display().to_string()); Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } } "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/bounce_dialog.rs index 04ad4d6..e3a5499 100644 --- a/crates/ui-egui/tests/bounce_dialog.rs +++ b/crates/ui-egui/tests/bounce_dialog.rs @@ -34,3 +34,21 @@ fn bounce_starts_in_the_session_folder_and_browse_picks_the_file_and_format() { other => panic!("{other:?}"), } } + +#[test] +fn bounce_of_a_session_with_a_relative_path_starts_in_the_default_folder() { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.path = Some("Midnight Groove.scraft".into()); + let name = engine.session().name.clone(); + let mut app = SoundApp::new(engine, None, Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + let mut fresh = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + assert!(fresh.dialogs.open_for_command(&fresh.engine, "file.bounce_mix")); + match (&app.dialogs.open, &fresh.dialogs.open) { + (Some(Dialog::Bounce { path, .. }), Some(Dialog::Bounce { path: unsaved, .. })) => { + assert_ne!(path.as_str(), format!("/{name} Bounce.wav")); + assert_eq!(path, unsaved, "the same folder as a session never saved"); + } + other => panic!("{other:?}"), + } +}