use std::time::Duration;
use super::action::{Action, Key};
use super::View;
use crate::audio::Mode;
pub struct Command {
pub name: &'static str,
pub args: &'static str,
pub help: &'static str,
pub view: Option<View>,
}
const fn any(name: &'static str, args: &'static str, help: &'static str) -> Command {
Command {
name,
args,
help,
view: None,
}
}
const fn only(view: View, name: &'static str, args: &'static str, help: &'static str) -> Command {
Command {
name,
args,
help,
view: Some(view),
}
}
use View::{Library, Playlists, Selection};
pub const COMMANDS: &[Command] = &[
any("help", "", "list these commands"),
any("keys", "", "list the keys"),
any("quit", "", "quit"),
any("view", "VIEW", "library, selection or playlists"),
any("next-view", "", "switch to the next view"),
any("down", "[N]", "move the cursor down N rows, default 1"),
any("up", "[N]", "move the cursor up N rows, default 1"),
any("first", "", "move the cursor to the first row"),
any("last", "", "move the cursor to the last row"),
any("play", "", "play the list in view from the cursor"),
any("search", "[QUERY]", "search the library; no query opens /"),
any("playlist", "NAME", "play a saved playlist"),
any("save", "[NAME]", "save the selection as a playlist"),
any("pause", "", "play or pause"),
any("next", "", "next track"),
any("prev", "", "previous track"),
any("stop", "", "stop"),
any(
"seek",
"TIME | +TIME | -TIME",
"seek to a time, or by one: 1:23, +10",
),
any(
"volume",
"PERCENT | +N | -N",
"set the volume, or change it: 60, +10",
),
any(
"speed",
"N | +N | -N",
"set varispeed in semitones, or change it",
),
any(
"mode",
"MODE | + | -",
"normal, shuffle, repeat, repeat-one; or cycle",
),
any("mark", "[TIME]", "mark the playing position, or a time"),
any("unmark", "", "undo the last mark"),
any("delmarks", "", "clear all marks in this track; asks y/n"),
any("next-mark", "", "seek to the next mark"),
any("prev-mark", "", "seek to the previous mark"),
any(
"map",
"[VIEW] KEY COMMAND",
"bind a key, in one view or in all",
),
any("unmap", "[VIEW] KEY", "remove a key binding"),
only(Library, "toggle", "", "select or unselect the track"),
only(Library, "clear-search", "", "show the whole library again"),
only(
Selection,
"remove",
"",
"remove the track from the selection",
),
only(Selection, "move", "+N | -N", "move the track N places"),
only(Selection, "clear", "", "empty the selection; asks y/n"),
only(
Playlists,
"add",
"",
"add the playlist's tracks to the selection",
),
only(Playlists, "delete", "", "delete the playlist; asks y/n"),
only(Playlists, "rename", "[NAME]", "rename the playlist"),
];
const MODES: &[(&str, Mode)] = &[
("normal", Mode::Normal),
("shuffle", Mode::Shuffle),
("repeat", Mode::Repeat),
("repeat-one", Mode::RepeatOne),
];
const VIEWS: &[(&str, View)] = &[
("library", Library),
("selection", Selection),
("playlists", Playlists),
];
pub fn view_name(view: View) -> &'static str {
VIEWS.iter().find(|v| v.1 == view).expect("every view").0
}
fn resolve<'a>(
word: &str,
names: impl Iterator<Item = &'a str> + Clone,
what: &str,
) -> Result<&'a str, String> {
if let Some(exact) = names.clone().find(|n| *n == word) {
return Ok(exact);
}
let matches: Vec<&str> = names.filter(|n| n.starts_with(word)).collect();
match matches.as_slice() {
[one] => Ok(one),
[] => Err(format!("unknown {what}: {word}")),
many => Err(format!("ambiguous {what} {word}: {}", many.join(", "))),
}
}
fn usable(view: Option<View>) -> impl Iterator<Item = &'static Command> + Clone {
COMMANDS
.iter()
.filter(move |c| c.view.is_none_or(|v| Some(v) == view))
}
fn resolve_command(word: &str, view: Option<View>) -> Result<&'static Command, String> {
let elsewhere = |c: &Command| {
let there = c.view.expect("usable in every view");
format!(":{} works in the {} view", c.name, view_name(there))
};
if let Some(c) = COMMANDS.iter().find(|c| c.name == word) {
return if usable(view).any(|u| u.name == c.name) {
Ok(c)
} else {
Err(elsewhere(c))
};
}
let find = |name: &str| COMMANDS.iter().find(|c| c.name == name).expect("resolved");
match resolve(word, usable(view).map(|c| c.name), "command") {
Ok(name) => Ok(find(name)),
Err(e) if e.starts_with("unknown") => {
match resolve(word, COMMANDS.iter().map(|c| c.name), "command") {
Ok(name) => Err(elsewhere(find(name))),
Err(_) => Err(e),
}
}
Err(e) => Err(e),
}
}
fn number(n: f64) -> String {
let rounded = (n * 1000.0).round() / 1000.0;
format!("{rounded}")
}
pub fn line(action: &Action, view: Option<View>) -> String {
use Action::*;
let time = |d: &Duration| number(d.as_secs_f64());
match action {
Quit => "quit".into(),
Help => "keys".into(),
CommandHelp => "help".into(),
ShowView(v) => format!("view {}", view_name(*v)),
NextView => "next-view".into(),
Cursor(1) => "down".into(),
Cursor(-1) => "up".into(),
Cursor(n) if *n > 0 => format!("down {n}"),
Cursor(n) => format!("up {}", -n),
CursorFirst => "first".into(),
CursorLast => "last".into(),
StartSearch => "search".into(),
Search(q) => format!("search {q}"),
ClearSearch => "clear-search".into(),
StartCommand => "command".into(),
Activate => "play".into(),
Add if view == Some(Library) => "toggle".into(),
Add => "add".into(),
Remove => "remove".into(),
MoveTrack(n) => format!("move {n:+}"),
ClearSelection => "clear".into(),
StartSave => "save".into(),
SaveAs(name) => format!("save {name}"),
DeletePlaylist => "delete".into(),
StartRename => "rename".into(),
RenameTo(name) => format!("rename {name}"),
PlayPlaylist(name) => format!("playlist {name}"),
TogglePause => "pause".into(),
Next => "next".into(),
Prev => "prev".into(),
Stop => "stop".into(),
SeekBy(n) => format!("seek {n:+}"),
SeekTo(d) => format!("seek {}", time(d)),
VolumeBy(v) => format!(
"volume {}{}",
if *v < 0.0 { "-" } else { "+" },
number(f64::from(v.abs()) * 100.0)
),
SetVolume(v) => format!("volume {}", number(f64::from(*v) * 100.0)),
SpeedBy(n) => format!("speed {n:+}"),
SetSpeed(n) => format!("speed {n}"),
CycleMode(true) => "mode +".into(),
CycleMode(false) => "mode -".into(),
SetMode(m) => format!(
"mode {}",
MODES.iter().find(|x| x.1 == *m).expect("every mode").0
),
Mark => "mark".into(),
MarkAt(d) => format!("mark {}", time(d)),
UndoMark => "unmark".into(),
ClearMarks => "delmarks".into(),
NextMark => "next-mark".into(),
PrevMark => "prev-mark".into(),
Map { view, key, action } => {
let target = match action {
Some(a) => line(a, *view),
None => "nop".into(),
};
match view {
Some(v) => format!("map {} {key} {target}", view_name(*v)),
None => format!("map {key} {target}"),
}
}
Unmap { view: Some(v), key } => format!("unmap {} {key}", view_name(*v)),
Unmap { view: None, key } => format!("unmap {key}"),
}
}
fn choose<T: Copy>(word: &str, table: &[(&str, T)], what: &str) -> Result<T, String> {
let names = || table.iter().map(|t| t.0);
let listed = || format!("{what}s: {}", names().collect::<Vec<_>>().join(", "));
if word.is_empty() {
return Err(listed());
}
match resolve(&word.to_ascii_lowercase(), names(), what) {
Ok(name) => Ok(table.iter().find(|t| t.0 == name).expect("resolved").1),
Err(e) if e.starts_with("unknown") => Err(listed()),
Err(e) => Err(e),
}
}
fn parse_time(text: &str) -> Result<Duration, String> {
let bad = || format!("not a time: {text} (try 1:23 or 90)");
let parts: Vec<&str> = text.split(':').collect();
if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) {
return Err(bad());
}
let (whole, last) = parts.split_at(parts.len() - 1);
let mut seconds: f64 = last[0].parse().map_err(|_| bad())?;
for (i, part) in whole.iter().rev().enumerate() {
let n: u64 = part.parse().map_err(|_| bad())?;
seconds += n as f64 * 60f64.powi(i as i32 + 1);
}
if !seconds.is_finite() || seconds < 0.0 {
return Err(bad());
}
Ok(Duration::from_secs_f64(seconds))
}
fn signed(text: &str) -> Option<(f64, &str)> {
match text.as_bytes().first() {
Some(b'+') => Some((1.0, &text[1..])),
Some(b'-') => Some((-1.0, &text[1..])),
_ => None,
}
}
fn unquote(text: &str) -> &str {
text.strip_prefix('"')
.and_then(|t| t.strip_suffix('"'))
.unwrap_or(text)
}
pub fn parse(line: &str, view: View) -> Result<Action, String> {
parse_in(line, Some(view))
}
pub fn key_target(target: &str, view: Option<View>) -> Result<Option<Action>, String> {
match target.trim() {
"" => Err("no command".into()),
"nop" => Ok(None),
"command" => Ok(Some(Action::StartCommand)),
target => match parse_in(target, view)? {
Action::Map { .. } | Action::Unmap { .. } => {
Err("a key cannot run :map or :unmap".into())
}
action => Ok(Some(action)),
},
}
}
pub fn only_view(target: &str) -> Option<View> {
if parse_in(target, None).is_ok() {
return None;
}
VIEWS
.iter()
.map(|v| v.1)
.find(|v| parse_in(target, Some(*v)).is_ok())
}
pub fn mode_named(name: &str) -> Result<Mode, String> {
choose(name, MODES, "mode")
}
pub fn view_named(name: &str) -> Option<View> {
VIEWS.iter().find(|v| v.0 == name).map(|v| v.1)
}
fn first_word(text: &str) -> (&str, &str) {
match text.split_once(char::is_whitespace) {
Some((word, rest)) => (word, rest.trim()),
None => (text, ""),
}
}
fn binding(rest: &str) -> Result<(Option<View>, Key, &str), String> {
let (first, after) = first_word(rest);
let (view, key, after) = match VIEWS.iter().find(|v| v.0 == first) {
Some(&(_, view)) if !after.is_empty() => {
let (key, after) = first_word(after);
(Some(view), key, after)
}
_ => (None, first, after),
};
Ok((view, Key::parse(key)?, after))
}
pub fn scope(view: Option<View>) -> String {
match view {
Some(v) => format!("in the {} view", view_name(v)),
None => "for all views".into(),
}
}
fn parse_in(line: &str, view: Option<View>) -> Result<Action, String> {
let line = line.trim();
let (word, rest) = match line.split_once(char::is_whitespace) {
Some((word, rest)) => (word, rest.trim()),
None => (line, ""),
};
if line.is_empty() {
return Err("no command".into());
}
let command = resolve_command(word, view)?;
let name = command.name;
let usage = || format!("usage: :{} {}", name, command.args);
let nothing = |action: Action| {
if rest.is_empty() {
Ok(action)
} else {
Err(format!(":{name} takes no arguments"))
}
};
let rows = |sign: i64| match rest {
"" => Ok(Action::Cursor(sign)),
n => match n.parse::<i64>() {
Ok(n) if n > 0 => Ok(Action::Cursor(sign * n)),
_ => Err(format!("not a number of rows: {n}")),
},
};
match name {
"help" => nothing(Action::CommandHelp),
"keys" => nothing(Action::Help),
"quit" => nothing(Action::Quit),
"view" => choose(rest, VIEWS, "view").map(Action::ShowView),
"next-view" => nothing(Action::NextView),
"down" => rows(1),
"up" => rows(-1),
"first" => nothing(Action::CursorFirst),
"last" => nothing(Action::CursorLast),
"play" => nothing(Action::Activate),
"search" if rest.is_empty() => Ok(Action::StartSearch),
"search" => Ok(Action::Search(rest.to_string())),
"playlist" if rest.is_empty() => Err(usage()),
"playlist" => Ok(Action::PlayPlaylist(unquote(rest).to_string())),
"save" if rest.is_empty() => Ok(Action::StartSave),
"save" => Ok(Action::SaveAs(unquote(rest).to_string())),
"pause" => nothing(Action::TogglePause),
"next" => nothing(Action::Next),
"prev" => nothing(Action::Prev),
"stop" => nothing(Action::Stop),
"seek" => match signed(rest) {
_ if rest.is_empty() => Err(usage()),
Some((sign, time)) => Ok(Action::SeekBy(
(sign * parse_time(time)?.as_secs_f64()).round() as i64,
)),
None => Ok(Action::SeekTo(parse_time(rest)?)),
},
"volume" => {
let number = |t: &str| {
t.parse::<f32>()
.map_err(|_| format!("not a volume: {rest}"))
};
match signed(rest) {
_ if rest.is_empty() => Err(usage()),
Some((sign, n)) => Ok(Action::VolumeBy(sign as f32 * number(n)? / 100.0)),
None => match number(rest)? {
v if (0.0..=100.0).contains(&v) => Ok(Action::SetVolume(v / 100.0)),
_ => Err("volume is 0 to 100".into()),
},
}
}
"speed" => {
let semitones = |t: &str| {
t.parse::<u32>()
.ok()
.filter(|n| *n <= 24)
.map(|n| n as i32)
.ok_or_else(|| format!("not a number of semitones: {rest}"))
};
match signed(rest) {
_ if rest.is_empty() => Err(usage()),
Some((sign, n)) => Ok(Action::SpeedBy(sign as i32 * semitones(n)?)),
None => match semitones(rest)? {
n if n <= 12 => Ok(Action::SetSpeed(n)),
_ => Err("speed is -12 to 12 semitones".into()),
},
}
}
"mode" => match rest {
"+" => Ok(Action::CycleMode(true)),
"-" => Ok(Action::CycleMode(false)),
_ => {
let typed = rest.split_whitespace().collect::<Vec<_>>().join("-");
choose(&typed, MODES, "mode").map(Action::SetMode)
}
},
"mark" if rest.is_empty() => Ok(Action::Mark),
"mark" => Ok(Action::MarkAt(parse_time(rest)?)),
"unmark" => nothing(Action::UndoMark),
"delmarks" => nothing(Action::ClearMarks),
"next-mark" => nothing(Action::NextMark),
"prev-mark" => nothing(Action::PrevMark),
"map" => {
let (view, key, target) = binding(rest)?;
if target.is_empty() {
return Err(usage());
}
let action = key_target(target, view).map_err(|e| {
match only_view(target).filter(|v| view != Some(*v)) {
Some(v) => format!("{e}; use map {} {key} {target}", view_name(v)),
None => e,
}
})?;
Ok(Action::Map {
view,
key,
action: action.map(Box::new),
})
}
"unmap" => match binding(rest)? {
(view, key, "") => Ok(Action::Unmap { view, key }),
_ => Err(usage()),
},
"toggle" | "add" => nothing(Action::Add),
"clear-search" => nothing(Action::ClearSearch),
"remove" => nothing(Action::Remove),
"move" => match signed(rest).map(|(sign, n)| (sign as i64, n.parse::<i64>())) {
Some((sign, Ok(n))) if n > 0 => Ok(Action::MoveTrack(sign * n)),
_ => Err(usage()),
},
"clear" => nothing(Action::ClearSelection),
"delete" => nothing(Action::DeletePlaylist),
"rename" if rest.is_empty() => Ok(Action::StartRename),
"rename" => Ok(Action::RenameTo(unquote(rest).to_string())),
_ => unreachable!("command {name} has no parser"),
}
}
pub fn completions(text: &str, view: View, playlists: &[String]) -> Vec<String> {
let Some((word, rest)) = text.split_once(' ') else {
return usable(Some(view))
.filter(|c| c.name.starts_with(text))
.map(|c| c.name.to_string())
.collect();
};
let Ok(command) = resolve_command(word, Some(view)) else {
return Vec::new();
};
let rest = rest.trim_start();
let choices: Vec<String> = match command.name {
"mode" => MODES.iter().map(|m| m.0.to_string()).collect(),
"view" => VIEWS.iter().map(|v| v.0.to_string()).collect(),
"playlist" | "rename" => playlists.to_vec(),
_ => Vec::new(),
};
let lower = rest.to_lowercase();
choices
.into_iter()
.filter(|c| c.to_lowercase().starts_with(&lower))
.map(|c| format!("{} {c}", command.name))
.collect()
}
pub const HISTORY_LEN: usize = 100;
#[derive(Debug, Default)]
pub struct History {
lines: Vec<String>,
}
impl History {
pub fn push(&mut self, line: &str) {
let line = line.trim();
if line.is_empty() || self.lines.last().is_some_and(|l| l == line) {
return;
}
if self.lines.len() == HISTORY_LEN {
self.lines.remove(0);
}
self.lines.push(line.to_string());
}
pub fn lines(&self) -> &[String] {
&self.lines
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct CommandLine {
pub text: String,
tab: Option<(Vec<String>, usize)>,
recall: Option<(String, usize)>,
}
impl CommandLine {
pub fn push(&mut self, c: char) {
self.text.push(c);
self.settle();
}
pub fn pop(&mut self) -> bool {
self.settle();
self.text.pop().is_some()
}
fn settle(&mut self) {
self.tab = None;
self.recall = None;
}
pub fn complete(&mut self, forward: bool, view: View, playlists: &[String]) {
self.recall = None;
let (choices, at) = match self.tab.take() {
Some((choices, at)) => {
let n = choices.len();
let at = if forward {
(at + 1) % n
} else {
(at + n - 1) % n
};
(choices, at)
}
None => {
let choices = completions(&self.text, view, playlists);
if choices.is_empty() {
return;
}
let at = if forward { 0 } else { choices.len() - 1 };
(choices, at)
}
};
self.text = choices[at].clone();
self.tab = Some((choices, at));
}
pub fn recall(&mut self, older: bool, history: &History) {
self.tab = None;
let lines = history.lines();
let (draft, at) = self
.recall
.take()
.unwrap_or_else(|| (self.text.clone(), lines.len()));
let found = if older {
lines[..at].iter().rposition(|l| l.starts_with(&draft))
} else {
lines
.iter()
.enumerate()
.skip(at + 1)
.find(|(_, l)| l.starts_with(&draft))
.map(|(i, _)| i)
};
match found {
Some(i) => {
self.text = lines[i].clone();
self.recall = Some((draft, i));
}
None if older => {
if at < lines.len() {
self.recall = Some((draft, at));
}
}
None => self.text = draft,
}
}
}