//! The menu bar, built from the incumbent's menu catalog so every item has its place. Items //! that map to a command are live; the rest are shown disabled (and listed in the parity report). use crate::{MainWindow, SoundApp}; use serde_json::{Value, json}; use soundcraft_engine::catalog; /// UI-layer commands: (id, label, catalog path or "", shortcut). pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("ui.dock", "Arrange Panel", "", None), ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None), ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")), ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")), ("window.mix_separate", "Mix in Separate Window", "", None), ("window.transport", "Transport", "Window > Transport", Some("Cmd+1")), ("window.big_counter", "Big Counter", "Window > Big Counter", Some("Cmd+3")), ("window.memory_locations", "Memory Locations", "Window > Memory Locations", Some("Cmd+5")), ("window.undo_history", "Undo History", "Window > Undo History", None), ("window.clip_list", "Clip List", "Window > Clip List", None), ("window.track_list", "Track List", "View > Other Displays > Track List", None), ("window.clip_list_view", "Clip List", "View > Other Displays > Clip List", None), ("window.narrow_mix", "Narrow Mix", "View > Narrow Mix", Some("Cmd+Alt+M")), ("window.session_info", "Session Info", "", None), ("window.audio_health", "Session Audio Health", "", None), ("window.about", "About SoundCraft", "", None), ("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")), ("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")), ("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None), ("window.universe", "Universe", "View > Other Displays > Universe", Some("Alt+7")), ("window.video", "Video", "Window > Video", Some("Cmd+9")), ("window.video_universe", "Video Universe", "Window > Video Universe", None), ("window.automation", "Automation", "Window > Automation", Some("Cmd+4")), ("window.color_palette", "Color Palette", "Window > Color Palette", None), ("window.disk_usage", "Disk Usage", "Window > Disk Usage", None), ("window.system_usage", "System Usage", "Window > System Usage", None), ("window.task_manager", "Task Manager", "Window > Task Manager", Some("Alt+'")), ("window.metadata", "Metadata Inspector", "Window > Metadata Inspector", None), ("window.event_list", "MIDI Event List", "Window > MIDI Event List", Some("Alt+=")), ("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", Some("Shift+K")), ("window.workspace", "Workspace", "Window > New Workspace > Default", Some("Alt+I")), ("window.workspace_presets", "Workspace", "Window > New Workspace > Track Presets", None), ("window.workspace_sounds", "Workspace", "Window > New Workspace > Soundbase", None), ("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", Some("Alt+J")), ("window.workspace_close", "Close All Workspaces", "Window > Workspaces > Close All Workspaces", None), ("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", Some("Cmd+Alt+J")), ("window.config_new", "New Configuration", "Window > Configurations > New Configuration...", None), ("window.config_update", "Update Active Configuration", "Window > Configurations > Update Active Configuration", None), ("window.config_auto_update", "Auto-Update Active Configuration", "Window > Configurations > Auto-Update Active Configuration", None), ("window.renderer", "Renderer", "Window > Renderer", None), ("window.ui_customization", "UI Customization", "Window > UI Customization", None), ("window.arrange_tile", "Tile", "Window > Arrange > Tile", None), ("window.arrange_tile_h", "Tile Horizontal", "Window > Arrange > Tile Horizontal", None), ("window.arrange_tile_v", "Tile Vertical", "Window > Arrange > Tile Vertical", None), ("window.arrange_cascade", "Cascade", "Window > Arrange > Cascade", None), ("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None), ("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None), ("window.clip_effects", "Clip Effects", "Window > Clip Effects", None), ("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")), ("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")), ("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")), ("window.beat_detective", "Beat Detective", "", None), ("window.tempo_ops", "Tempo Operations", "", None), ("window.time_ops", "Time Operations", "", None), ("window.midi_ops", "MIDI Operations", "", None), ("window.rtp", "MIDI Real-Time Properties", "", None), ("window.playback_engine", "Playback Engine", "", None), ("ui.audio_device", "Audio Device", "", None), ("window.io_setup", "I/O Setup", "", None), ("window.shortcuts", "Keyboard Shortcuts", "", None), ("view.mix_section", "Mix Window View", "", None), ("view.scroll_into_view", "Scroll Into View", "", None), ("ui.new_tracks_dialog", "New Tracks…", "", None), ("ui.bounce_dialog", "Bounce Mix…", "", None), ("ui.set", "Set UI State", "", None), ("ui.theme", "Appearance", "", None), ]; /// Extra catalog mappings handled by the UI layer. pub fn ui_aliases() -> Vec<(&'static str, &'static str)> { let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect(); v.extend(mix_view_aliases().iter().copied()); v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process"))); v.extend(MENU_WINDOWS.iter().copied()); v } /// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame. fn mix_view_aliases() -> &'static [(&'static str, &'static str)] { static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new(); ALIASES.get_or_init(|| { [ "Inserts A-E", "Inserts F-J", "Sends A-E", "Sends F-J", "I/O", "Track Color", "Comments", "EQ Curve", "Meters and Faders", "All", "Minimal", "Mic Preamps", "Instruments", "Object", ] .into_iter() .map(|sec| { let path: &'static str = Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()); (path, "view.mix_section") }) .collect() }) } /// AudioSuite catalog entries → our processes (functional equivalents, our own plugins). pub const AUDIOSUITE: &[(&str, &str)] = &[ ("AudioSuite > EQ > Channel Strip", "channel_strip"), ("AudioSuite > EQ > EQ3 1-Band", "eq_1band"), ("AudioSuite > EQ > EQ3 7-Band", "eq_7band"), ("AudioSuite > Dynamics > BF-76", "compressor"), ("AudioSuite > Dynamics > Channel Strip", "channel_strip"), ("AudioSuite > Dynamics > Dyn3 Compressor/Limiter", "compressor"), ("AudioSuite > Dynamics > Dyn3 De-Esser", "de_esser"), ("AudioSuite > Dynamics > Dyn3 Expander/Gate", "expander_gate"), ("AudioSuite > Dynamics > Maxim", "maximizer"), ("AudioSuite > Pitch Shift > Pitch II", "pitch_shift"), ("AudioSuite > Pitch Shift > Pitch Shift Legacy", "pitch_shift"), ("AudioSuite > Pitch Shift > Time Shift", "time_stretch"), ("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"), ("AudioSuite > Reverb > D-Verb", "room_reverb"), ("AudioSuite > Delay > Mod Delay III", "mod_delay"), ("AudioSuite > Modulation > Sci-Fi", "flanger"), ("AudioSuite > Harmonic > Eleven Lite", "saturator"), ("AudioSuite > Harmonic > Lo-Fi", "lofi"), ("AudioSuite > Harmonic > Recti-Fi", "rectifier"), ("AudioSuite > Harmonic > SansAmp PSA-1", "saturator"), ("AudioSuite > Other > DC Offset Removal", "dc_offset_removal"), ("AudioSuite > Other > Duplicate", "duplicate"), ("AudioSuite > Other > Gain", "gain"), ("AudioSuite > Other > Invert", "invert"), ("AudioSuite > Other > Normalize", "normalize"), ("AudioSuite > Other > Reverse", "reverse"), ("AudioSuite > Other > Signal Generator", "signal_generator"), ("AudioSuite > Other > Time Compression Expansion", "time_stretch"), ]; /// Commands that open a dialog when invoked from a menu (programmatic calls never do). const DIALOG_COMMANDS: &[&str] = &[ "clip.clear", "track.new", "file.bounce_mix", "session.new", "file.import_audio", "file.import_midi", "session.open", "session.save", "session.save_as", "session.save_copy", "track.group", "edit.repeat", "edit.shift", "edit.fades_create", "setup.session", "event.quantize", "event.transpose", "edit.strip_silence", "clip.rename", ]; /// Shared menu hierarchy for egui and native desktop hosts. pub struct MenuNode { pub label: String, pub path: String, pub children: Vec, } fn insert_path(roots: &mut Vec, line: &str) { let parts: Vec<&str> = line.split(" > ").collect(); let mut level = roots; let mut path = String::new(); for (i, p) in parts.iter().enumerate() { if i > 0 { path.push_str(" > "); } path.push_str(p); let idx = match level.iter().position(|n| n.label == *p) { Some(i) => i, None => { level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() }); level.len() - 1 } }; let Some(node) = level.get_mut(idx) else { break }; level = &mut node.children; } } pub fn tree() -> Vec { let mut roots: Vec = Vec::new(); for line in catalog::catalog() { insert_path(&mut roots, line); } insert_path(&mut roots, "Window > Reset Panel Layout"); // Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point. if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") { let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1); clip.children.insert(at, MenuNode { label: "Remove Sync Point".into(), path: "Clip > Remove Sync Point".into(), children: Vec::new() }); } // Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282). // It creates a clip rather than acting on the selected ones, so it leads the Clip menu. insert_path(&mut roots, "Clip > New MIDI Clip"); if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") { clip.children.rotate_right(1); } for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } roots } pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::current(); egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show( ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("SoundCraft", |ui| { if ui.button("About SoundCraft").clicked() { app.ui.show_about = true; } if ui.button("Session Info").clicked() { app.ui.show_session_info = true; } if ui.button("Session Audio Health").clicked() { let _ = app.run("window.audio_health", json!({"value": true})); } ui.separator(); if ui.button("Quit").clicked() { let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({})); } }); let extra = ui_aliases(); for root in tree() { ui.menu_button(&root.label, |ui| { for c in &root.children { menu_node(app, ui, c, &extra); if c.path == "File > Open" { crate::recent::menu(app, ui); } if c.path == "Track > New..." { instrument_menu(app, ui); } } if root.label == "AudioSuite" { ui.separator(); ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim)); } if root.label == "Window" { ui.separator(); let mut separate = app.ui.mix_separate; if ui .checkbox(&mut separate, "Mix in Separate Window") .on_hover_text("Show Edit and Mix at once, e.g. one per monitor") .changed() { let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate})); ui.close(); } } }); } let title = format!( "{}{} — {}", app.engine.session().name, if app.engine.is_dirty() { " *" } else { "" }, if app.ui.window == MainWindow::Edit || app.ui.mix_separate { "Edit" } else { "Mix" } ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(title).color(t.text_dim)); }); }); }, ); } /// Track › Instrument: the instrument picker for the selected instrument track (#374). fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) { match instrument_track(app.engine.session()) { Some(id) => { ui.menu_button("Instrument", |ui| crate::mix_window::instrument_menu(app, ui, id)); } None => { ui.add_enabled(false, egui::Button::new("Instrument")).on_disabled_hover_text("Select an instrument track"); } } } /// The first selected instrument track, in track order. fn instrument_track(s: &soundcraft_model::Session) -> Option { s.tracks.iter().find(|t| t.kind == soundcraft_model::TrackKind::Instrument && s.edit.selected_tracks.contains(&t.id)).map(|t| t.id) } fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) { if !n.children.is_empty() { ui.menu_button(&n.label, |ui| { for c in &n.children { menu_node(app, ui, c, extra); } }); return; } let id = command_for_path(&n.path, extra); let (enabled, checked) = item_state(app, &n.path, id.as_deref()); let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone()); let label = if checked { format!("✔ {label}") } else { label }; let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or(""); let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac())); if ui.add_enabled(enabled, btn).clicked() && let Some(id) = id { invoke_menu(app, &id, &n.path); ui.close(); } } /// Resolve the command behind a menu leaf, including UI dialog overrides. pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option { catalog::implemented_by(path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == path).map(|(_, w)| w.to_string())) } /// Enabled and checked state shared by all menu presentations. pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { let enabled = match id { Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") => { true } Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)), None => false, }; (enabled, checked_state(app, path, id)) } fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { let e = &app.engine.session().edit; match id { Some("options.loop_playback") => e.loop_playback, Some("options.loop_record") => e.loop_record, Some("options.quickpunch") => e.quickpunch, Some("options.pre_post_roll") => e.pre_post_roll, Some("options.link_timeline_edit") => e.link_timeline_edit, Some("options.link_track_edit") => e.link_track_edit, Some("options.insertion_follows_playback") => e.insertion_follows_playback, Some("options.tab_to_transient") => e.tab_to_transient, Some("options.automation_follows_edit") => e.automation_follows_edit, Some("options.markers_follow_edit") => e.markers_follow_edit, Some("options.click") => e.click, Some("options.delay_compensation") => e.delay_compensation, Some("options.pre_fader_metering") => e.pre_fader_metering, Some("view.ruler") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.rulers.iter().any(|x| x == r)), Some("view.main_counter") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.main_counter.id() == r), Some("window.mix") => app.ui.window == MainWindow::Mix, Some("window.edit") => app.ui.window == MainWindow::Edit, Some("window.track_list") => app.ui.show_tracks_list, Some("window.clip_list_view") | Some("window.clip_list") => app.ui.show_clip_list, Some("window.narrow_mix") => app.ui.narrow_mix, Some("window.renderer") => app.extra.show_renderer, Some("window.ui_customization") => app.extra.show_ui_customization, Some("window.config_auto_update") => app.extra.auto_update_config, Some("view.mix_section") => path.rsplit(" > ").next().map(mix_section_id).is_some_and(|s| app.ui.mix_views.iter().any(|v| v == s)), _ => false, } } fn ruler_id(label: &str) -> &'static str { match label { "Bars|Beats" => "bars_beats", "Min:Secs" => "min_secs", "Timecode" => "timecode", "Feet+Frames" => "feet_frames", "Samples" => "samples", "Tempo" => "tempo", "Meter" => "meter", "Markers" => "markers", "Key Signature" => "key", "Chord Symbols" => "chords", _ => "", } } fn mix_section_id(label: &str) -> &'static str { match label { "Inserts A-E" => "inserts_ae", "Inserts F-J" => "inserts_fj", "Sends A-E" => "sends_ae", "Sends F-J" => "sends_fj", "I/O" => "io", "Track Color" => "color", "Comments" => "comments", "EQ Curve" => "eq_curve", "Meters and Faders" => "meters", "All" => "all", "Minimal" => "minimal", "Mic Preamps" => "mic_preamps", "Instruments" => "instruments", "Object" => "object", _ => "", } } /// Parameters implied by the menu item a command was reached through. fn params_for(path: &str, id: &str) -> Value { let leaf = path.rsplit(" > ").next().unwrap_or(""); match id { "view.ruler" => json!({"ruler": ruler_id(leaf)}), "view.main_counter" => json!({"format": ruler_id(leaf)}), "options.scrolling" => { json!({"mode": match leaf { "No Scrolling" => "none", "After Playback" => "after_playback", "Continuous" => "continuous", "Center Playhead" => "center", _ => "page" }}) } "options.solo_mode" => { json!({"mode": if leaf.starts_with("AFL") { "afl" } else if leaf.starts_with("PFL") { "pfl" } else { "sip" }, "xor": leaf.starts_with("X-OR")}) } "clip.rating" => json!({"rating": leaf.parse::().unwrap_or(0)}), "track.change_width" => json!({"format": leaf}), "view.mix_section" => json!({"section": mix_section_id(leaf)}), "audiosuite.process" => json!({"process": AUDIOSUITE.iter().find(|(p, _)| *p == path).map_or("", |(_, x)| *x)}), _ => json!({}), } } /// Menu items that open a SoundCraft window when clicked (programmatic calls still run the command). const MENU_WINDOWS: &[(&str, &str)] = &[ ("Setup > Appearance", "window.ui_customization"), ("Setup > Hardware...", "window.playback_engine"), ("Setup > Playback Engine...", "window.playback_engine"), ("Setup > I/O...", "window.io_setup"), ("Setup > Keyboard Shortcuts...", "window.shortcuts"), ("Setup > Session", "setup.session"), ("Event > Beat Detective", "window.beat_detective"), ("Event > Tempo Operations > Tempo Operations Window", "window.tempo_ops"), ("Event > Time Operations > Time Operations Window", "window.time_ops"), ("Event > MIDI Operations > MIDI Operations Window", "window.midi_ops"), ("Event > MIDI Real-Time Properties", "window.rtp"), ]; /// The shortcut of an engine or window command. pub fn shortcut_for(id: &str) -> Option<&'static str> { soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut) } /// Undo and Redo name the step they would undo or redo; other items keep their catalog label. pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option { let e = &app.engine; match id? { "edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))), "edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))), _ => None, } } /// Menu-style invocation: may open a dialog. pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) && w.starts_with("window.") { let _ = app.run(w, json!({"value": true})); return; } if id == "audiosuite.process" { let p = params_for(path, id); app.ui.audiosuite = p.get("process").and_then(Value::as_str).map(str::to_string); return; } if wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) { return; } // These ids only exist to open a dialog from the menu. Programmatic `app.run` refuses them. if id == "ui.new_tracks_dialog" { let _ = app.dialogs.open_for_command(&app.engine, "track.new"); return; } if id == "ui.bounce_dialog" { let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix"); return; } let params = params_for(path, id); let _ = crate::dialogs::run_or_ask(app, id, params); } /// Keyboard shortcut for a dialog command, or one that would close a modified session: open its /// dialog. Returns true when handled. pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool { (wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({})) } /// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path. fn wants_dialog(id: &str) -> bool { DIALOG_COMMANDS.contains(&id) || id == "file.score_setup" || crate::dialogs::takes_path(id) } /// Handle UI-layer commands. Returns None when `id` is not a UI command. pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> { if matches!(id, "ui.dock" | "ui.dock.reset") { return Some(crate::docking::command(app, id == "ui.dock.reset", p)); } if let Some(v) = crate::extra_windows::run(app, id, p) { return Some(Ok(v)); } if id == "ui.audio_device" { return Some(crate::windows::audio_device_command(app, p)); } let toggle = |v: &mut bool| { *v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v); json!({"value": *v}) }; let r = match id { "window.mix" => { app.ui.window = MainWindow::Mix; app.focus_mix = app.ui.mix_separate; json!({"window": "mix"}) } "window.mix_separate" => { let v = toggle(&mut app.ui.mix_separate); app.focus_mix = app.ui.mix_separate; v } "window.edit" => { app.ui.window = MainWindow::Edit; json!({"window": "edit"}) } "window.toggle_mix_edit" => { if app.ui.mix_separate { // Both are on screen: bring the Mix window forward instead. app.focus_mix = true; } else { app.ui.window = if app.ui.window == MainWindow::Edit { MainWindow::Mix } else { MainWindow::Edit }; } json!({"window": app.ui.window}) } "window.transport" => toggle(&mut app.ui.show_transport), "window.big_counter" => toggle(&mut app.ui.show_big_counter), "window.memory_locations" => toggle(&mut app.ui.show_memory_locations), "window.undo_history" => toggle(&mut app.ui.show_undo_history), "window.clip_list" | "window.clip_list_view" => toggle(&mut app.ui.show_clip_list), "window.track_list" => toggle(&mut app.ui.show_tracks_list), "window.narrow_mix" => toggle(&mut app.ui.narrow_mix), "window.session_info" => toggle(&mut app.ui.show_session_info), "window.audio_health" => { app.audio_health_report = None; toggle(&mut app.ui.show_audio_health) } "window.about" => toggle(&mut app.ui.show_about), "window.midi_editor" => toggle(&mut app.ui.show_midi_editor), "window.midi_front" => { app.ui.show_midi_editor = true; json!({"value": true}) } "window.midi_back" => { app.ui.show_midi_editor = false; json!({}) } "window.automation" => toggle(&mut app.ui.show_automation), "window.clip_effects" | "window.clip_effects_dock" => toggle(&mut app.ui.show_clip_effects), "window.search" => toggle(&mut app.ui.show_search), "window.score" => toggle(&mut app.ui.show_score), "window.beat_detective" => toggle(&mut app.ui.show_beat_detective), "window.tempo_ops" => toggle(&mut app.ui.show_tempo_ops), "window.time_ops" => toggle(&mut app.ui.show_time_ops), "window.midi_ops" => toggle(&mut app.ui.show_midi_ops), "window.rtp" => toggle(&mut app.ui.show_rtp), "window.playback_engine" => toggle(&mut app.ui.show_playback_engine), "window.io_setup" => toggle(&mut app.ui.show_io_setup), "window.shortcuts" => toggle(&mut app.ui.show_shortcuts), "window.color_palette" => toggle(&mut app.ui.show_color_palette), "window.disk_usage" => toggle(&mut app.ui.show_disk_usage), "window.system_usage" => toggle(&mut app.ui.show_system_usage), "window.task_manager" => toggle(&mut app.ui.show_task_manager), "window.metadata" => toggle(&mut app.ui.show_metadata), "window.event_list" => toggle(&mut app.ui.show_event_list), "window.midi_keyboard" => toggle(&mut app.ui.show_midi_keyboard), "window.workspace" | "window.workspace_presets" | "window.workspace_sounds" | "window.workspace_front" => { app.ui.show_workspace = true; json!({"value": true}) } "window.workspace_close" => { app.ui.show_workspace = false; json!({"value": false}) } "window.configurations" => toggle(&mut app.ui.show_configurations), "window.config_new" | "window.config_update" => { if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) { return Some(Err(error)); } let mut snap = app.ui.clone(); snap.configurations.clear(); let v = serde_json::to_value(&snap).unwrap_or_default(); if id == "window.config_update" && !app.ui.configurations.is_empty() { if let Some(last) = app.ui.configurations.last_mut() { last.1 = v; } } else { let n = app.ui.configurations.len() + 1; app.ui.configurations.push((format!("Configuration {n}"), v)); } json!({"configurations": app.ui.configurations.len()}) } "window.arrange_tile" | "window.arrange_tile_h" | "window.arrange_tile_v" | "window.arrange_cascade" => { if let Err(error) = crate::docking::arrange(app) { app.ui.status = format!("Panel windows could not be arranged: {error}"); } app.arrange_request = true; json!({}) } "window.universe" => toggle(&mut app.ui.show_universe), "window.video" => toggle(&mut app.ui.show_video), "window.video_universe" => toggle(&mut app.ui.show_video_universe), "window.close" | "window.hide_floating" => { app.ui.show_transport = false; app.ui.show_big_counter = false; app.ui.show_memory_locations = false; app.ui.show_undo_history = false; app.ui.show_session_info = false; app.ui.show_audio_health = false; app.ui.show_automation = false; app.ui.plugin_windows.clear(); app.ui.audiosuite = None; json!({}) } "view.mix_section" => { let sec = p.get("section").and_then(Value::as_str).unwrap_or(""); match sec { "all" => { app.ui.mix_views = ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(ToString::to_string).collect() } "minimal" => app.ui.mix_views.clear(), "" => return Some(Err("`section` required".into())), s => { if app.ui.mix_views.iter().any(|v| v == s) { app.ui.mix_views.retain(|v| v != s); } else { app.ui.mix_views.push(s.to_string()); } } } json!({"mix_views": app.ui.mix_views}) } "view.scroll_into_view" => { // `track` (id or name), else the first selected track: the palette passes no params. let track = match soundcraft_engine::cmd::track_param(&app.engine, id, p, "track") { Ok(Some(t)) => t, Ok(None) => match app.engine.session().edit.selected_tracks.first() { Some(t) => *t, None => return Some(Err("view.scroll_into_view: pass `track` or select a track".into())), }, Err(e) => return Some(Err(e.to_string())), }; app.edit_scroll_into_view = Some(track); app.mix_scroll_into_view = Some(track); json!({"track": track.0}) } "ui.new_tracks_dialog" => { return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into())); } "ui.bounce_dialog" => { return Some(Err("ui.bounce_dialog opens a dialog from the menu only; call file.bounce_mix with a path".into())); } "ui.theme" => { let Some(mode) = p.get("mode").and_then(Value::as_str) else { return Some(Ok(json!({"mode": app.ui.theme.id()}))); }; let Some(mode) = crate::theme::ThemeMode::parse(mode) else { return Some(Err(format!("unknown theme `{mode}` (system, light or dark)"))); }; app.ui.theme = mode; json!({"mode": mode.id()}) } "ui.set" => match serde_json::to_value(&app.ui) { Ok(mut cur) => { if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) { for (k, v) in patch { obj.insert(k.clone(), v.clone()); } } match serde_json::from_value::(cur) { Ok(ns) => { if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) { return Some(Err(error)); } crate::docking::cancel_replaced_state(app, &ns); app.ui = ns; json!({"ui": app.ui}) } Err(e) => return Some(Err(format!("bad ui state: {e}"))), } } Err(e) => return Some(Err(e.to_string())), }, _ => return None, }; Some(Ok(r)) } /// Parity including UI-layer commands. pub fn parity() -> Value { let extra = ui_aliases(); catalog::parity_with(&extra) } #[cfg(test)] mod tests { use super::*; use crate::{Services, SoundApp}; #[test] fn mix_view_aliases_are_allocated_once() { let first = ui_aliases(); let second = ui_aliases(); let a = first.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap(); let b = second.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap(); assert!(std::ptr::eq(a, b)); assert_eq!(first.iter().filter(|(path, _)| path.contains("Mix Window Views")).count(), 14); } #[test] fn programmatic_dialog_commands_stay_closed_and_the_menu_opens_them() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); let err = app.run("ui.new_tracks_dialog", json!({})).unwrap_err(); assert!(err.contains("track.new"), "{err}"); let err = app.run("ui.bounce_dialog", json!({})).unwrap_err(); assert!(err.contains("file.bounce_mix"), "{err}"); assert!(app.dialogs.open_name().is_none()); invoke_menu(&mut app, "ui.new_tracks_dialog", "Track > New..."); assert_eq!(app.dialogs.open_name(), Some("new_tracks")); app.dialogs.open = None; invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix..."); assert_eq!(app.dialogs.open_name(), Some("bounce")); } #[test] fn save_asks_for_a_path_until_the_session_has_one() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); invoke_menu(&mut app, "session.save", "File > Save Session"); assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as")); app.dialogs.open = None; assert!(invoke_shortcut_dialog(&mut app, "session.save")); app.dialogs.open = None; let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id())); let path = dir.join("Song.scraft"); app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap(); app.run("track.new", json!({})).unwrap(); assert!(!invoke_shortcut_dialog(&mut app, "session.save")); invoke_menu(&mut app, "session.save", "File > Save Session"); assert!(app.dialogs.open.is_none()); assert!(!app.engine.is_dirty()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn track_instrument_menu_targets_the_selected_instrument_track() { let mut e = soundcraft_engine::Engine::default(); let audio = e.execute("track.new", &json!({"kind": "audio"})).unwrap()["tracks"][0].as_u64().unwrap(); assert_eq!(instrument_track(e.session()), None); let inst = e.execute("track.new", &json!({"kind": "instrument"})).unwrap()["tracks"][0].as_u64().unwrap(); e.execute("edit.select", &json!({"tracks": [audio]})).unwrap(); assert_eq!(instrument_track(e.session()), None, "an audio track has no instrument"); e.execute("edit.select", &json!({"tracks": [audio, inst]})).unwrap(); assert_eq!(instrument_track(e.session()).map(|t| t.0), Some(inst)); } }