Files
soundcraft/crates/ui-egui/src/dialogs.rs
T
2026-10-13 05:12:31 -04:00

788 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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<ClipId>, sources: Vec<SourceId>, 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
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { 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<Dialog>,
/// Runs once the session has been saved from `SaveChanges`.
after_save: Option<(String, Value)>,
}
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::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "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() });
}
/// 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<ClipId>, sources: Vec<SourceId>) -> 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 { 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")
|| 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<Value, String> {
if ask_to_save(app, id, &params) {
return Ok(Value::Null);
}
app.run(id, params)
}
/// 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<Dialog> {
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<String> {
let listed: std::collections::BTreeSet<ClipId> = 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::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create 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"));
}
});
ui.checkbox(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"));
}
});
});
ui.checkbox(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::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 { name, edit, mix, members } => {
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(name);
});
ui.horizontal(|ui| {
ui.checkbox(edit, "Edit group");
ui.checkbox(mix, "Mix group");
});
ui.label("Tracks");
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
for (_, n, on) in members.iter_mut() {
ui.checkbox(on, n.as_str());
}
});
if buttons(ui, "OK", enter) {
let ids: Vec<u64> = members.iter().filter(|m| m.2).map(|m| m.0).collect();
action = Some(("track.group".into(), json!({"name": name, "edit": edit, "mix": mix, "tracks": ids})));
}
}
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();
});
ui.checkbox(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::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
keep = false;
app.dialogs.after_save = None;
ctx.memory_mut(|m| m.data.remove::<bool>(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 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 saving
&& let Some((then, params)) = app.dialogs.after_save.take()
&& !app.engine.is_dirty()
&& let Err(e) = app.run(&then, params)
{
app.ui.status = e;
}
if keep {
app.dialogs.open = Some(d);
}
}
/// Cancel/OK row; returns true when OK was pressed (or Enter).
fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
let mut pressed = enter;
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));
}
if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent)).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<String, f32> = 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<String, Value> = 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<ClipId> = 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."
);
}
}