Skip to main content

player_scope/
control.rs

1//! Command parsing and dispatch for the player UI.
2//!
3//! This module is intentionally small and backend-agnostic. The UI can parse
4//! prompt input into a [`ParsedCommand`], then dispatch it into any type that
5//! implements [`ControlEventHandler`].
6
7/// All commands currently represented by the UI and prompt parser.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Command {
10    Load,
11    Play,
12    Pause,
13    Quit,
14    Terminate,
15    MainView,
16    Next,
17    Prev,
18    Goto,
19    Label,
20    Loop,
21    ClearLoop,
22    Volume,
23    VolumeUp,
24    VolumeDown,
25    Mute,
26    Unmute,
27    Gain,
28    Pitch,
29    Cut,
30    Rec,
31    Mic,
32    Save,
33    Playlist,
34    PlaylistTop,
35    PlaylistBottom,
36    PlaylistUp,
37    PlaylistDown,
38    Webradio,
39    Scope,
40    Spectro,
41    Transcribe,
42    Help,
43}
44
45impl Command {
46    /// Returns the canonical prompt name for this command.
47    pub const fn name(self) -> &'static str {
48        match self {
49            Self::Load => "load",
50            Self::Play => "play",
51            Self::Pause => "pause",
52            Self::Quit => "quit",
53            Self::Terminate => "terminate",
54            Self::MainView => "mainview",
55            Self::Next => "next",
56            Self::Prev => "prev",
57            Self::Goto => "goto",
58            Self::Label => "label",
59            Self::Loop => "loop",
60            Self::ClearLoop => "clearloop",
61            Self::Volume => "volume",
62            Self::VolumeUp => "+",
63            Self::VolumeDown => "-",
64            Self::Mute => "mute",
65            Self::Unmute => "unmute",
66            Self::Gain => "gain",
67            Self::Pitch => "pitch",
68            Self::Cut => "cut",
69            Self::Rec => "rec",
70            Self::Mic => "mic",
71            Self::Save => "save",
72            Self::Playlist => "playlist",
73            Self::PlaylistTop => "top",
74            Self::PlaylistBottom => "bottom",
75            Self::PlaylistUp => "up",
76            Self::PlaylistDown => "down",
77            Self::Webradio => "webradio",
78            Self::Scope => "scope",
79            Self::Spectro => "spectro",
80            Self::Transcribe => "transcribe",
81            Self::Help => "help",
82        }
83    }
84}
85
86/// Canonical command plus accepted aliases.
87#[derive(Debug, Clone, Copy)]
88pub struct CommandSpec {
89    /// The command routed by this spec.
90    pub command: Command,
91    /// Extra names accepted by the parser.
92    pub aliases: &'static [&'static str],
93}
94
95/// Commands and aliases accepted by [`parse_command`].
96pub const COMMAND_SPECS: &[CommandSpec] = &[
97    CommandSpec {
98        command: Command::Load,
99        aliases: &["l", "open"],
100    },
101    CommandSpec {
102        command: Command::Play,
103        aliases: &["p", "▶"],
104    },
105    CommandSpec {
106        command: Command::Pause,
107        aliases: &["a", "stop", "⏸", "⏹"],
108    },
109    CommandSpec {
110        command: Command::Quit,
111        aliases: &["q"],
112    },
113    CommandSpec {
114        command: Command::Terminate,
115        aliases: &["shutdown", "x", "leave", "exit"],
116    },
117    CommandSpec {
118        command: Command::MainView,
119        aliases: &["main", "player", "back", "close"],
120    },
121    CommandSpec {
122        command: Command::Next,
123        aliases: &["n"],
124    },
125    CommandSpec {
126        command: Command::Prev,
127        aliases: &["v", "previous"],
128    },
129    CommandSpec {
130        command: Command::Goto,
131        aliases: &["g", "seek"],
132    },
133    CommandSpec {
134        command: Command::Label,
135        aliases: &["e"],
136    },
137    CommandSpec {
138        command: Command::Loop,
139        aliases: &[],
140    },
141    CommandSpec {
142        command: Command::ClearLoop,
143        aliases: &["unloop"],
144    },
145    CommandSpec {
146        command: Command::Volume,
147        aliases: &["vol"],
148    },
149    CommandSpec {
150        command: Command::VolumeUp,
151        aliases: &[],
152    },
153    CommandSpec {
154        command: Command::VolumeDown,
155        aliases: &[],
156    },
157    CommandSpec {
158        command: Command::Mute,
159        aliases: &["m"],
160    },
161    CommandSpec {
162        command: Command::Unmute,
163        aliases: &["u"],
164    },
165    CommandSpec {
166        command: Command::Gain,
167        aliases: &[],
168    },
169    CommandSpec {
170        command: Command::Pitch,
171        aliases: &["t"],
172    },
173    CommandSpec {
174        command: Command::Cut,
175        aliases: &["c"],
176    },
177    CommandSpec {
178        command: Command::Rec,
179        aliases: &["r", "record"],
180    },
181    CommandSpec {
182        command: Command::Mic,
183        aliases: &["i"],
184    },
185    CommandSpec {
186        command: Command::Save,
187        aliases: &["s"],
188    },
189    CommandSpec {
190        command: Command::Playlist,
191        aliases: &["y"],
192    },
193    CommandSpec {
194        command: Command::PlaylistTop,
195        aliases: &[],
196    },
197    CommandSpec {
198        command: Command::PlaylistBottom,
199        aliases: &[],
200    },
201    CommandSpec {
202        command: Command::PlaylistUp,
203        aliases: &[],
204    },
205    CommandSpec {
206        command: Command::PlaylistDown,
207        aliases: &[],
208    },
209    CommandSpec {
210        command: Command::Webradio,
211        aliases: &["radio", "w"],
212    },
213    CommandSpec {
214        command: Command::Scope,
215        aliases: &["o", "oscillo", "oscilloscope"],
216    },
217    CommandSpec {
218        command: Command::Spectro,
219        aliases: &["spectroscope"],
220    },
221    CommandSpec {
222        command: Command::Transcribe,
223        aliases: &[],
224    },
225    CommandSpec {
226        command: Command::Help,
227        aliases: &["?"],
228    },
229];
230
231/// A parsed command action plus the remaining prompt arguments.
232#[derive(Debug, Clone)]
233pub struct ParsedCommand {
234    /// Resolved command.
235    pub command: Command,
236    #[allow(dead_code)]
237    pub raw: String,
238    #[allow(dead_code)]
239    pub raw_action: String,
240    /// Whitespace-separated arguments after the command/action.
241    pub args: Vec<String>,
242}
243
244impl ParsedCommand {
245    /// Returns an argument by index.
246    pub fn arg(&self, index: usize) -> Option<&str> {
247        self.args.get(index).map(String::as_str)
248    }
249
250    /// Joins all arguments back into one space-separated string.
251    pub fn rest(&self) -> String {
252        self.args.join(" ")
253    }
254}
255
256/// Errors returned by [`parse_command`].
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum ParseCommandError {
259    /// The prompt contained no command text.
260    Empty,
261    /// The action did not match any command or alias.
262    Unknown(String),
263    /// A short alias matched more than one command.
264    Ambiguous { input: String, matches: Vec<String> },
265}
266
267/// Parses prompt text into a command event.
268pub fn parse_command(input: &str) -> Result<ParsedCommand, ParseCommandError> {
269    let raw = input.trim();
270    if raw.is_empty() {
271        return Err(ParseCommandError::Empty);
272    }
273
274    let mut parts = raw.split_whitespace();
275    let raw_action = parts.next().unwrap_or_default().to_lowercase();
276    let args = parts.map(ToOwned::to_owned).collect::<Vec<_>>();
277    let command = resolve_command(raw_action.as_str())?;
278
279    Ok(ParsedCommand {
280        command,
281        raw: raw.to_owned(),
282        raw_action,
283        args,
284    })
285}
286
287/// Returns all canonical command names as a slash-separated help string.
288pub fn command_names() -> String {
289    COMMAND_SPECS
290        .iter()
291        .map(|spec| spec.command.name())
292        .collect::<Vec<_>>()
293        .join("/")
294}
295
296fn resolve_command(action: &str) -> Result<Command, ParseCommandError> {
297    if let Some(spec) = COMMAND_SPECS.iter().find(|spec| {
298        spec.command.name() == action || spec.aliases.iter().any(|alias| *alias == action)
299    }) {
300        return Ok(spec.command);
301    }
302
303    if action.chars().count() <= 2 && action.chars().all(|ch| ch.is_ascii_alphanumeric()) {
304        let matches = COMMAND_SPECS
305            .iter()
306            .filter(|spec| spec.command.name().starts_with(action))
307            .map(|spec| spec.command)
308            .collect::<Vec<_>>();
309
310        return match matches.as_slice() {
311            [] => Err(ParseCommandError::Unknown(action.to_owned())),
312            [command] => Ok(*command),
313            _ => Err(ParseCommandError::Ambiguous {
314                input: action.to_owned(),
315                matches: matches
316                    .into_iter()
317                    .map(Command::name)
318                    .map(ToOwned::to_owned)
319                    .collect(),
320            }),
321        };
322    }
323
324    Err(ParseCommandError::Unknown(action.to_owned()))
325}
326
327/// Event sink implemented by the UI or a future playback backend.
328pub trait ControlEventHandler {
329    fn on_load(&mut self, _event: &ParsedCommand) {}
330    fn on_play(&mut self, _event: &ParsedCommand) {}
331    fn on_pause(&mut self, _event: &ParsedCommand) {}
332    fn on_quit(&mut self, _event: &ParsedCommand) {}
333    fn on_terminate(&mut self, _event: &ParsedCommand) {}
334    fn on_mainview(&mut self, _event: &ParsedCommand) {}
335    fn on_next(&mut self, _event: &ParsedCommand) {}
336    fn on_prev(&mut self, _event: &ParsedCommand) {}
337    fn on_goto(&mut self, _event: &ParsedCommand) {}
338    fn on_label(&mut self, _event: &ParsedCommand) {}
339    fn on_loop(&mut self, _event: &ParsedCommand) {}
340    fn on_clear_loop(&mut self, _event: &ParsedCommand) {}
341    fn on_volume(&mut self, _event: &ParsedCommand) {}
342    fn on_volume_up(&mut self, _event: &ParsedCommand) {}
343    fn on_volume_down(&mut self, _event: &ParsedCommand) {}
344    fn on_mute(&mut self, _event: &ParsedCommand) {}
345    fn on_unmute(&mut self, _event: &ParsedCommand) {}
346    fn on_gain(&mut self, _event: &ParsedCommand) {}
347    fn on_pitch(&mut self, _event: &ParsedCommand) {}
348    fn on_cut(&mut self, _event: &ParsedCommand) {}
349    fn on_rec(&mut self, _event: &ParsedCommand) {}
350    fn on_mic(&mut self, _event: &ParsedCommand) {}
351    fn on_save(&mut self, _event: &ParsedCommand) {}
352    fn on_playlist(&mut self, _event: &ParsedCommand) {}
353    fn on_playlist_top(&mut self, _event: &ParsedCommand) {}
354    fn on_playlist_bottom(&mut self, _event: &ParsedCommand) {}
355    fn on_playlist_up(&mut self, _event: &ParsedCommand) {}
356    fn on_playlist_down(&mut self, _event: &ParsedCommand) {}
357    fn on_webradio(&mut self, _event: &ParsedCommand) {}
358    fn on_scope(&mut self, _event: &ParsedCommand) {}
359    fn on_spectro(&mut self, _event: &ParsedCommand) {}
360    fn on_transcribe(&mut self, _event: &ParsedCommand) {}
361    fn on_help(&mut self, _event: &ParsedCommand) {}
362}
363
364/// Routes a parsed command to the matching [`ControlEventHandler`] callback.
365pub fn dispatch(handler: &mut impl ControlEventHandler, event: &ParsedCommand) {
366    match event.command {
367        Command::Load => handler.on_load(event),
368        Command::Play => handler.on_play(event),
369        Command::Pause => handler.on_pause(event),
370        Command::Quit => handler.on_quit(event),
371        Command::Terminate => handler.on_terminate(event),
372        Command::MainView => handler.on_mainview(event),
373        Command::Next => handler.on_next(event),
374        Command::Prev => handler.on_prev(event),
375        Command::Goto => handler.on_goto(event),
376        Command::Label => handler.on_label(event),
377        Command::Loop => handler.on_loop(event),
378        Command::ClearLoop => handler.on_clear_loop(event),
379        Command::Volume => handler.on_volume(event),
380        Command::VolumeUp => handler.on_volume_up(event),
381        Command::VolumeDown => handler.on_volume_down(event),
382        Command::Mute => handler.on_mute(event),
383        Command::Unmute => handler.on_unmute(event),
384        Command::Gain => handler.on_gain(event),
385        Command::Pitch => handler.on_pitch(event),
386        Command::Cut => handler.on_cut(event),
387        Command::Rec => handler.on_rec(event),
388        Command::Mic => handler.on_mic(event),
389        Command::Save => handler.on_save(event),
390        Command::Playlist => handler.on_playlist(event),
391        Command::PlaylistTop => handler.on_playlist_top(event),
392        Command::PlaylistBottom => handler.on_playlist_bottom(event),
393        Command::PlaylistUp => handler.on_playlist_up(event),
394        Command::PlaylistDown => handler.on_playlist_down(event),
395        Command::Webradio => handler.on_webradio(event),
396        Command::Scope => handler.on_scope(event),
397        Command::Spectro => handler.on_spectro(event),
398        Command::Transcribe => handler.on_transcribe(event),
399        Command::Help => handler.on_help(event),
400    }
401}