//! Modal dialogs opened by menu-style invocations. use crate::SoundApp; use crate::theme::{Tokens, bold}; use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; use soundcraft_model::{ClipId, GroupId, Session, SourceId, TrackId}; use soundcraft_time::Samples; #[derive(Debug, Clone)] pub enum Dialog { NewTracks { count: u32, format: String, kind: String, timebase: String, name: String }, NewSession { name: String, sample_rate: u32, demo: bool }, Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, RenameMarker { id: soundcraft_model::MarkerId, name: String }, ClearClips { clips: Vec, sources: Vec, message: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler Spot(crate::spot::Spot), Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, // `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…). Group { id: Option, 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)>, /// A session the browser read while the open one had changes, waiting for `SaveChanges`. pending_session: Option<(String, Vec)>, } impl Dialogs { pub fn open_name(&self) -> Option<&'static str> { self.open.as_ref().map(|d| match d { Dialog::NewTracks { .. } => "new_tracks", Dialog::NewSession { .. } => "new_session", Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", Dialog::RenameClip { .. } => "rename_clip", Dialog::RenameMarker { .. } => "rename_marker", Dialog::ClearClips { .. } => "clear_clips", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", Dialog::TempoChange { .. } => "tempo_change", Dialog::Spot(_) => "spot", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { id: None, .. } => "group", Dialog::Group { id: Some(_), .. } => "modify_group", Dialog::Session { .. } => "session", Dialog::ScoreSetup { .. } => "score_setup", Dialog::SaveChanges { .. } => "save_changes", }) } pub fn open_rename_track(&mut self, id: TrackId, name: &str) { self.open = Some(Dialog::RenameTrack { id, name: name.to_string() }); } pub fn open_rename_clip(&mut self, id: ClipId, name: &str) { self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); } pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) { self.open = Some(Dialog::RenameMarker { id, name: name.to_string() }); } /// Groups list › Modify Group…: the creation dialog, pre-filled from an existing group. False /// when the group is gone (deleted between the click and the menu). pub fn open_modify_group(&mut self, e: &Engine, id: GroupId) -> bool { let s = e.session(); let Some(g) = s.group(id) else { return false }; let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), g.members.contains(&t.id))).collect(); self.open = Some(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members }); true } /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; self.open = Some(Dialog::ClearClips { clips, sources, message }); true } /// 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 { "clip.clear" => { let ed = &e.session().edit; return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); } "track.new" => { Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } } "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()) .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() }, "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" 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(), // 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() }, "edit.shift" => { Dialog::Number { cmd: id.into(), title: "Shift (seconds)".into(), key: "by_seconds".into(), value: 1.0, suffix: "s".into() } } "event.transpose" => { Dialog::Number { cmd: id.into(), title: "Transpose".into(), key: "semitones".into(), value: 12.0, suffix: "semitones".into() } } "event.quantize" => { Dialog::Number { cmd: id.into(), title: "Quantize strength".into(), key: "strength".into(), value: 100.0, suffix: "%".into() } } "edit.fades_create" => Dialog::Fades { shape: "equal power".into() }, "edit.strip_silence" => Dialog::StripSilence { threshold: -48.0, min_ms: 50.0, pre_ms: 5.0, post_ms: 20.0 }, "track.group" => { let s = e.session(); let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), s.edit.selected_tracks.contains(&t.id))).collect(); Dialog::Group { id: None, name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members } } "setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() }, "file.score_setup" => { 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, }, }); true } } /// 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" | OPEN_PENDING) || 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) } /// What `SaveChanges` carries on with to open the session the browser read. const OPEN_PENDING: &str = "ui.open_pending_session"; /// Opens a session the browser read as bytes (picked or dropped), asking first when the open one /// has changes. The read finishes later than the pick, so the question is asked only now. pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec) { if crate::bounce::refuses_session_switch(app) { return; } app.dialogs.pending_session = Some((name, bytes)); if !ask_to_save(app, OPEN_PENDING, &json!({})) { open_pending_session(app); } } fn open_pending_session(app: &mut SoundApp) { let Some((name, bytes)) = app.dialogs.pending_session.take() else { return }; if crate::bounce::refuses_session_switch(app) { return; } let _ = app.run("transport.stop", json!({})); app.ui.status = match soundcraft_engine::io::open_session_bytes(&mut app.engine, &name, &bytes) { Ok(missing) if missing.is_empty() => format!("Opened {name}"), Ok(missing) => format!("Opened {name} ({} missing media)", missing.len()), Err(e) => e.to_string(), }; } /// 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 { path_key(id).is_some() } fn path_key(id: &str) -> Option<&'static str> { let spec = soundcraft_engine::command_specs().iter().find(|c| c.id == id)?; if spec.params.starts_with("{path?") && id != "file.print_score" { None } else if spec.params.starts_with("{path") { Some("path") } else if spec.params.starts_with("{dir") { Some("dir") } else { None } } fn path_dialog(id: &str, home: &str, name: &str) -> Option { let spec = soundcraft_engine::command_specs().iter().find(|c| c.id == id)?; let key = path_key(id)?; let importing = id.contains("import") || id.contains("open"); let ext = match id { "file.export_midi" => ".mid", "file.export_sibelius" => ".musicxml", "file.print_score" => ".svg", "file.export_clip_groups" => ".scgrp", "file.export_session_text" => ".txt", _ if id.contains("session") => ".scraft", _ => "", }; let path = if importing || key == "dir" { format!("{home}/") } else { format!("{home}/{name}{ext}") }; let title = format!("{} — {}", spec.menu.last().copied().unwrap_or("File"), spec.label.trim_end_matches("...")); Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() }) } fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option { let listed: std::collections::BTreeSet = clips.iter().copied().collect(); let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect(); let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect(); let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }; let what = match (names.as_slice(), files.as_slice()) { ([], []) => return None, ([name], []) => format!("the clip \"{name}\""), ([], [f]) => format!("the file \"{}\"", f.name), (c, []) => count(c.len(), "clip"), ([], f) => count(f.len(), "file"), (c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")), }; let mut text = format!("Remove {what} from the session?"); let used: Vec<(&str, usize)> = files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect(); let n: usize = used.iter().map(|u| u.1).sum(); let users = if n == 1 { "a clip on the timeline, which is removed too.".to_string() } else { format!("{n} clips on the timeline, which are removed too.") }; match used.as_slice() { [] => {} [_] if files.len() == 1 => text += &format!(" It is used by {users}"), u if u.len() == files.len() => text += &format!(" They are used by {users}"), [(name, _)] => text += &format!(" \"{name}\" is used by {users}"), many => text += &format!(" {} of the files are used by {users}", many.len()), } if !files.is_empty() && files.iter().all(|f| !f.unsaved) { text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." }; } Some(text) } fn default_dir() -> String { #[cfg(not(target_arch = "wasm32"))] { std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| ".".into()) } #[cfg(target_arch = "wasm32")] { String::new() } } /// Show the open dialog, if any. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); crate::bounce::show(app, ctx); 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", Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", Dialog::RenameMarker { .. } => "Rename Marker", Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Spot(_) => "Spot", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { id: None, .. } => "Create Group", Dialog::Group { id: Some(_), .. } => "Modify 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)); let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)); egui::Window::new(egui::RichText::new(&title).font(bold(13.0))) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) .show(ctx, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); match &mut d { Dialog::NewTracks { count, format, kind, timebase, name } => { ui.horizontal(|ui| { ui.label("Create"); ui.add(egui::DragValue::new(count).range(1..=128)); ui.label("new"); egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { for f in soundcraft_model::ChannelFormat::ALL { ui.selectable_value(format, f.label().to_string(), f.label()); } }); egui::ComboBox::from_id_salt("nt_kind") .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) .show_ui(ui, |ui| { for k in soundcraft_model::TrackKind::ALL { if ui.selectable_label(kind == k.id(), k.label()).clicked() { *kind = k.id().to_string(); *name = k.default_name().to_string(); } } }); ui.label("in"); egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( ui, |ui| { ui.selectable_value(timebase, "samples".to_string(), "Samples"); ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); }, ); ui.label("Name:"); ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); }); if buttons(ui, "Create", enter) { action = Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); } } Dialog::NewSession { name, sample_rate, demo } => { ui.horizontal(|ui| { ui.label("Name"); ui.text_edit_singleline(name); }); egui::ComboBox::from_label("Sample Rate").selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| { for r in soundcraft_time::SampleRate::COMMON { ui.selectable_value(sample_rate, r, format!("{r} Hz")); } }); crate::widgets::checkbox(ui, demo, "Start from the demo session"); if buttons(ui, "Create", enter) { action = Some(( "session.new".into(), json!({"name": name, "sample_rate": sample_rate, "template": if *demo { "demo" } else { "blank" }}), )); } } Dialog::Bounce { path, format, bit_depth, normalize } => { ui.horizontal(|ui| { ui.label("File"); 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| { for f in ["wav", "aiff", "flac"] { ui.selectable_value(format, f.to_string(), f.to_uppercase()); } }); egui::ComboBox::from_label("Bit Depth").selected_text(format!("{bit_depth}-bit")).show_ui(ui, |ui| { for b in ["16", "24", "32f"] { ui.selectable_value(bit_depth, b.to_string(), format!("{b}-bit")); } }); }); crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS"); ui.label( egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.") .small() .color(Tokens::current().text_dim), ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); crate::bounce::start( app, &json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), ); keep = false; } } Dialog::RenameTrack { id, name } => { name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("track.rename".into(), json!({"track": id.0, "name": name}))); } } Dialog::RenameClip { id, name } => { name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } Dialog::RenameMarker { id, name } => { name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name}))); } } Dialog::ClearClips { clips, sources, message } => { ui.set_max_width(340.0); ui.label(message.as_str()); if buttons(ui, "Remove", enter) { action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true}))); } } Dialog::PathPrompt { cmd, path, key, .. } => { ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); 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() && let Some(p) = pick(cmd, path) { *path = p; } let file = soundcraft_engine::io::session_file(path); let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file); if replaces { ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy())); } if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) { let mut v = json!({}); v[key.as_str()] = json!(path); if replaces { v["replace"] = json!(true); } action = Some((cmd.clone(), v)); } } Dialog::Number { cmd, key, value, suffix, .. } => { ui.horizontal(|ui| { ui.add(egui::DragValue::new(value).speed(0.1)); ui.label(suffix.as_str()); }); if buttons(ui, "OK", enter) { let v = if key == "by_seconds" { json!({"by": {"seconds": value}}) } else { json!({key.as_str(): value}) }; action = Some((cmd.clone(), v)); } } Dialog::TempoChange { at, bpm } => { ui.horizontal(|ui| { // Same limits the engine enforces (time crate `valid_bpm`). ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)); ui.label("bpm"); }); if buttons(ui, "OK", enter) { action = Some(tempo_change_action(*at, *bpm)); } } Dialog::Spot(d) => { crate::spot::fields(ui, app.engine.session(), d); if buttons(ui, "OK", enter) { keep = false; action = d.command(app.engine.session()); } } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); } if buttons(ui, "OK", enter) { action = Some(("edit.fades_create".into(), json!({"shape": shape}))); } } Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms")); ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); if buttons(ui, "Strip", enter) { action = Some(( "edit.strip_silence".into(), json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}), )); } } Dialog::Group { id, name, edit, mix, members } => { ui.horizontal(|ui| { ui.label("Name"); ui.text_edit_singleline(name); }); ui.horizontal(|ui| { crate::widgets::checkbox(ui, edit, "Edit group"); crate::widgets::checkbox(ui, mix, "Mix group"); }); ui.label("Tracks"); egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { for (_, n, on) in members.iter_mut() { crate::widgets::checkbox(ui, on, n.as_str()); } }); let ids: Vec = members.iter().filter(|m| m.2).map(|m| m.0).collect(); // Both commands reject an empty group, so don't offer an OK that can only fail. if buttons_enabled(ui, "OK", enter, !ids.is_empty()) { let mut params = json!({"name": name, "edit": edit, "mix": mix, "tracks": ids}); let cmd = match id { Some(g) => { params["group"] = json!(g.0); "track.group_modify" } None => "track.group", }; action = Some((cmd.into(), params)); } } Dialog::Session { frame_rate, bit_depth } => { egui::ComboBox::from_label("Timecode Rate").selected_text(frame_rate.as_str()).show_ui(ui, |ui| { for r in soundcraft_time::FrameRate::ALL { ui.selectable_value(frame_rate, r.label().to_string(), r.label()); } }); egui::ComboBox::from_label("Bit Depth").selected_text(bit_depth.as_str()).show_ui(ui, |ui| { for b in ["16", "24", "32f"] { ui.selectable_value(bit_depth, b.to_string(), b); } }); if buttons(ui, "OK", enter) { action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth}))); } } Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => { egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { ui.label("Title"); ui.text_edit_singleline(title); ui.end_row(); ui.label("Composer"); ui.text_edit_singleline(composer); ui.end_row(); ui.label("Bars per system"); ui.add(egui::DragValue::new(bars_per_system).range(1..=16)); ui.end_row(); }); crate::widgets::checkbox(ui, show_track_names, "Show track names"); if buttons(ui, "OK", enter) { action = Some(( "file.score_setup".into(), json!({"title": title, "composer": composer, "bars_per_system": bars_per_system, "show_track_names": show_track_names}), )); } } 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; app.dialogs.pending_session = 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; match run_then(app, &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 saving && let Some((then, params)) = app.dialogs.after_save.take() && !app.engine.is_dirty() && let Err(e) = run_then(app, &then, params) { app.ui.status = e; } if keep { app.dialogs.open = Some(d); } } /// Runs a dialog's command, or what `SaveChanges` carries on with. fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result { if cmd == OPEN_PENDING { open_pending_session(app); return Ok(Value::Null); } app.run(cmd, params) } /// Cancel/OK row; returns true when OK was pressed (or Enter). fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { buttons_enabled(ui, ok, enter, true) } /// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works. fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool { let mut pressed = enter && ready; ui.separator(); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true)); } let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent); if ui.add_enabled(ready, b).clicked() { pressed = true; } }); 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})) } fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true; let title = soundcraft_dsp::plugin_info(&process).map_or_else(|| process.replace('_', " "), |p| p.name.to_string()); egui::Window::new(format!("AudioSuite · {title}")).open(&mut open).default_width(360.0).show(ctx, |ui| { let key = egui::Id::new(("as_params", process.clone())); let mut params: std::collections::BTreeMap = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_default(); if let Some(info) = soundcraft_dsp::plugin_info(&process) { for p in info.params { let v = params.entry(p.id.to_string()).or_insert(p.default); ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name)); } } else { match process.as_str() { "normalize" => { let v = params.entry("target_db".into()).or_insert(-0.1); ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB")); } "time_stretch" => { let v = params.entry("ratio".into()).or_insert(1.0); ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio")); } "pitch_shift" => { let v = params.entry("semitones".into()).or_insert(0.0); ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones")); } "varispeed" => { let v = params.entry("speed".into()).or_insert(1.0); ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed")); } _ => { ui.label("No parameters."); } } } ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone())); let hint = if soundcraft_dsp::is_generator(&process) { "Fills the selection on the selected tracks with a new clip." } else { "Processes the selected clips and replaces them with rendered audio." }; ui.label(egui::RichText::new(hint).small()); if ui.button(egui::RichText::new("Render").strong()).clicked() { let p: serde_json::Map = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); let _ = app.run("audiosuite.process", json!({"process": process, "params": p})); } }); if !open { app.ui.audiosuite = None; } } #[cfg(test)] mod tests { use super::*; #[test] fn file_commands_get_path_prompts_with_sensible_names() { let e = soundcraft_engine::demo::demo_engine(); let mut d = Dialogs::default(); for (id, ext) in [("file.export_sibelius", ".musicxml"), ("file.print_score", ".svg"), ("file.export_clip_groups", ".scgrp")] { assert!(takes_path(id), "{id}"); assert!(d.open_for_command(&e, id), "{id}"); match d.open.take() { Some(Dialog::PathPrompt { cmd, path, key, .. }) => { assert_eq!(cmd, id); assert_eq!(key, "path"); assert!(path.ends_with(ext), "{path}"); } other => panic!("{id}: {other:?}"), } } // Optional-path commands run directly. assert!(!takes_path("file.send_to_sibelius")); assert!(!takes_path("edit.copy")); assert!(d.open_for_command(&e, "file.score_setup")); 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); assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command"); assert_eq!(params["at"], 96_000); assert_eq!(params["bpm"], 133.5); } #[test] fn clear_always_asks_and_names_the_timeline_clips_that_go_too() { let mut e = soundcraft_engine::demo::demo_engine(); let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap(); let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats")); let kick_clips: Vec = e.session().clips_using(kick).map(|c| c.id).collect(); let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id; let message = |d: &Dialogs| match &d.open { Some(Dialog::ClearClips { message, .. }) => message.clone(), other => panic!("{other:?}"), }; let mut d = Dialogs::default(); assert!(!d.open_for_command(&e, "clip.clear")); e.execute("edit.select", &json!({"clips": [snare]})).unwrap(); assert!(d.open_for_command(&e, "clip.clear")); assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?"); e.execute("edit.select", &json!({"sources": [kick]})).unwrap(); assert!(d.open_for_command(&e, "clip.clear")); assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick])); assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too."); assert!(d.open_clear(&e, kick_clips.clone(), vec![kick])); assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?"); assert!(d.open_clear(&e, kick_clips, vec![kick, hats])); assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too."); e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false); assert!(d.open_clear(&e, vec![], vec![kick, hats])); assert_eq!( message(&d), "Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk." ); } }