soundcraft/crates/engine/src/cmd/mod.rs

362 lines
12 KiB
Rust

//! The command registry. One module per menu area; each exposes `specs()`.
use crate::{Engine, EngineError, Result};
use serde_json::Value;
use soundcraft_model::{ClipId, TrackId};
use soundcraft_time::{Range, Samples};
use std::sync::OnceLock;
mod audio_midi;
mod audiosuite;
mod clap;
mod clip;
mod clip_list;
mod clip_more;
mod edit;
mod edit_more;
mod event;
mod event_more;
mod file;
mod health;
mod instrument;
mod midi;
mod mix;
mod more_util;
mod options;
mod plugin_state;
mod query;
pub(crate) mod sampler;
mod setup_more;
mod surround;
mod tab;
mod track;
mod track_more;
mod transport;
mod video;
mod view;
mod view_more;
mod vst3;
mod zoom_toggle;
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
pub type Enabled = fn(&Engine) -> std::result::Result<(), String>;
/// A registered command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu path, e.g. `["Edit", "Trim"]`; empty = not in menus.
pub menu: &'static [&'static str],
pub shortcut: Option<&'static str>,
/// Human/agent parameter documentation.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub journal: bool,
pub undoable: bool,
/// Only touches `edit.zoom`, through `Engine::zoom_mut`.
pub view: bool,
}
/// Serializable description of a command.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommandInfo {
pub id: String,
pub label: String,
pub menu: Vec<String>,
pub shortcut: Option<String>,
pub params: String,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
impl CommandSpec {
pub fn info(&self, e: &Engine) -> CommandInfo {
let en = (self.enabled)(e);
CommandInfo {
id: self.id.into(),
label: self.label.into(),
menu: self.menu.iter().map(|s| s.to_string()).collect(),
shortcut: self.shortcut.map(str::to_string),
params: self.params.into(),
enabled: en.is_ok(),
disabled_reason: en.err(),
}
}
}
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants.
#[macro_export]
macro_rules! cmd {
(query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) };
(noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) };
(view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) };
(@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view }
};
($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) };
}
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: OnceLock<Vec<CommandSpec>> = OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(file::specs());
v.extend(edit::specs());
v.extend(edit_more::specs());
v.extend(view::specs());
v.extend(track::specs());
v.extend(clip::specs());
v.extend(event::specs());
v.extend(mix::specs());
v.extend(options::specs());
v.extend(transport::specs());
v.extend(tab::specs());
v.extend(query::specs());
v.extend(health::specs());
v.extend(audiosuite::specs());
v.extend(audio_midi::specs());
v.extend(midi::specs());
v.extend(view_more::specs());
v.extend(track_more::specs());
v.extend(clip_more::specs());
v.extend(clip_list::specs());
v.extend(event_more::specs());
v.extend(setup_more::specs());
v.extend(clap::specs());
v.extend(vst3::specs());
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
v
})
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
// ---- enablement --------------------------------------------------------------------------
pub fn always(_: &Engine) -> std::result::Result<(), String> {
Ok(())
}
pub fn has_tracks(e: &Engine) -> std::result::Result<(), String> {
if e.session().tracks.is_empty() { Err("the session has no tracks".into()) } else { Ok(()) }
}
pub fn has_selection(e: &Engine) -> std::result::Result<(), String> {
let s = e.session();
if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { Err("nothing is selected".into()) } else { Ok(()) }
}
pub fn has_range(e: &Engine) -> std::result::Result<(), String> {
let s = e.session();
if (s.edit.selected_tracks.is_empty() || s.edit.selection.is_empty()) && s.edit.selected_clips.is_empty() {
Err("make an edit selection first".into())
} else {
Ok(())
}
}
pub fn can_undo(e: &Engine) -> std::result::Result<(), String> {
if e.can_undo() { Ok(()) } else { Err("nothing to undo".into()) }
}
pub fn can_redo(e: &Engine) -> std::result::Result<(), String> {
if e.can_redo() { Ok(()) } else { Err("nothing to redo".into()) }
}
pub fn has_clipboard(e: &Engine) -> std::result::Result<(), String> {
if e.clipboard.tracks.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
}
// ---- params ------------------------------------------------------------------------------
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams(cmd.to_string(), msg.into())
}
pub fn f64_or(p: &Value, key: &str, d: f64) -> f64 {
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d)
}
pub fn f32_or(p: &Value, key: &str, d: f32) -> f32 {
f64_or(p, key, f64::from(d)) as f32
}
pub fn i64_or(p: &Value, key: &str, d: i64) -> i64 {
p.get(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64))).unwrap_or(d)
}
pub fn bool_or(p: &Value, key: &str, d: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(d)
}
pub fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
/// Resolve a track from `track` (id number or name) params.
pub fn track_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<TrackId>> {
let Some(v) = p.get(key) else { return Ok(None) };
let s = e.session();
if let Some(n) = v.as_u64() {
return s.track(TrackId(n)).map(|t| Some(t.id)).ok_or_else(|| bad(cmd, format!("no track with id {n}")));
}
if let Some(name) = v.as_str() {
return s.track_by_name(name).map(|t| Some(t.id)).ok_or_else(|| bad(cmd, format!("no track named `{name}`")));
}
Err(bad(cmd, format!("`{key}` must be a track id or name")))
}
/// Tracks to act on: `tracks` (array of ids/names) or `track`, else the selected tracks.
pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
let s = e.session();
let mut out = Vec::new();
for v in arr {
let id = match (v.as_u64(), v.as_str()) {
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
(_, Some(name)) => s.track_by_name(name).map(|t| t.id),
_ => None,
};
out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?);
}
return Ok(out);
}
if let Some(t) = track_param(e, cmd, p, "track")? {
return Ok(vec![t]);
}
Ok(e.target_tracks())
}
/// Like `tracks_param` but errors when empty.
pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
let t = tracks_param(e, cmd, p)?;
if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) }
}
pub use soundcraft_time::MAX_POSITION;
/// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format.
/// Values are clamped to ±[`MAX_POSITION`].
pub fn position_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<Samples>> {
Ok(position_param_raw(e, cmd, p, key)?.map(|v| v.clamp(-MAX_POSITION, MAX_POSITION)))
}
fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<Samples>> {
let Some(v) = p.get(key) else { return Ok(None) };
let s = e.session();
if let Some(n) = v.as_i64() {
return Ok(Some(n));
}
if let Some(f) = v.as_f64() {
return Ok(Some(soundcraft_time::to_samples(f)));
}
if let Some(secs) = v.get("seconds").and_then(Value::as_f64) {
return Ok(Some(s.sample_rate.samples(secs)));
}
if let Some(text) = v.as_str() {
// Try each format, starting with an explicit prefix like "bars:5|1|000".
let (fmt, body) = match text.split_once(':') {
Some((f, b)) if soundcraft_time::TimeFormat::from_id(f).is_some() => (soundcraft_time::TimeFormat::from_id(f), b),
_ => (None, text),
};
let fmts: Vec<soundcraft_time::TimeFormat> = match fmt {
Some(f) => vec![f],
None => {
let mut v = vec![s.edit.main_counter];
v.extend(soundcraft_time::TimeFormat::ALL);
v
}
};
for f in fmts {
if let Ok(x) = s.parse_position(body, f) {
return Ok(Some(x));
}
}
return Err(bad(cmd, format!("cannot parse position `{text}`")));
}
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
}
/// Range from `start`/`end` params, else the edit selection.
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
let st = position_param(e, cmd, p, "start")?;
let en = position_param(e, cmd, p, "end")?;
let len = position_param(e, cmd, p, "length")?;
match (st, en, len) {
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
(Some(a), None, None) => Ok(Range::point(a)),
_ => Ok(e.session().edit.selection),
}
}
pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
}
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
}
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
return vec![ClipId(n)];
}
let s = e.session();
if !s.edit.selected_clips.is_empty() {
return s.edit.selected_clips.clone();
}
// Clips touched by the edit selection on selected tracks.
let r = s.edit.selection;
s.tracks
.iter()
.filter(|t| s.edit.selected_tracks.contains(&t.id))
.flat_map(|t| t.clips().iter().filter(move |c| if r.is_empty() { c.range().contains(r.start) } else { c.range().overlaps(&r) }).map(|c| c.id))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_unique_and_documented() {
let mut seen = std::collections::HashSet::new();
for c in command_specs() {
assert!(seen.insert(c.id), "duplicate command id {}", c.id);
assert!(!c.label.is_empty());
assert!(c.id.contains('.'), "ids are namespaced: {}", c.id);
}
assert!(command_specs().len() > 150, "only {} commands", command_specs().len());
}
#[test]
fn every_command_survives_empty_params() {
// No command may panic with `{}` params on an empty or demo session.
for demo in [false, true] {
for c in command_specs() {
if matches!(c.id, "app.quit") {
continue;
}
let mut e = if demo { crate::demo::demo_engine() } else { Engine::default() };
let r = e.execute(c.id, &serde_json::json!({}));
if let Err(EngineError::Internal(id, msg)) = r {
panic!("{id} panicked: {msg}");
}
}
}
}
}