mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
776 lines
36 KiB
Rust
776 lines
36 KiB
Rust
//! 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<Vec<(&str, &str)>> = 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<MenuNode>,
|
||
}
|
||
|
||
fn insert_path(roots: &mut Vec<MenuNode>, 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<MenuNode> {
|
||
let mut roots: Vec<MenuNode> = 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<soundcraft_model::TrackId> {
|
||
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<String> {
|
||
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::<u8>().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<String> {
|
||
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<Result<Value, String>> {
|
||
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::<crate::UiState>(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));
|
||
}
|
||
}
|