use std::path::PathBuf;
use std::time::Duration;
use crate::action::{Action, Key, Keymap, Slicing, Zoom};
use crate::{Display, View};
use playr_core::audio::Mode;
use playr_core::samples::MAX_SLICES;
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, Sampler, Selection};
pub const COMMANDS: &[Command] = &[
any("help", "", "list these commands"),
any("keys", "", "list the keys for this view"),
any("quit", "", "quit"),
any("view", "VIEW", "library, selection, playlists or sampler"),
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("scan", "DIR", "add a directory to the library"),
any("open", "PATH", "play a file or directory, and select it"),
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 -",
),
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(
"slice",
"region|marks|N|onsets [S]",
"write samples from the region or the track",
),
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"),
only(
Sampler,
"zoom",
"+ | - | all",
"zoom in, out, or to the whole track",
),
only(
Sampler,
"display",
"[envelope|db|braille]",
"draw the waveform another way",
),
only(Sampler, "write", "", "write the slices :slice planned"),
only(Sampler, "discard", "", "discard the slices :slice planned"),
];
const MODES: &[(&str, Mode)] = &Mode::NAMES;
const VIEWS: &[(&str, View)] = &[
("library", Library),
("selection", Selection),
("playlists", Playlists),
("sampler", Sampler),
];
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}"),
Scan(dir) => format!("scan {}", dir.display()),
Open(paths) => {
let paths: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
format!("open {}", paths.join(" "))
}
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(),
Zoom(crate::action::Zoom::In) => "zoom +".into(),
Zoom(crate::action::Zoom::Out) => "zoom -".into(),
Zoom(crate::action::Zoom::All) => "zoom all".into(),
Display(None) => "display".into(),
Display(Some(d)) => format!("display {}", d.name()),
WriteSlices => "write".into(),
DiscardSlices => "discard".into(),
Slice(Slicing::Region) => "slice region".into(),
Slice(Slicing::Marks) => "slice marks".into(),
Slice(Slicing::Equal(n)) => format!("slice {n}"),
Slice(Slicing::Onsets(None)) => "slice onsets".into(),
Slice(Slicing::Onsets(Some(s))) => format!("slice onsets {}", number(f64::from(*s))),
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 path(text: &str) -> PathBuf {
let text = unquote(text);
let home = || std::env::home_dir().unwrap_or_default();
match text.strip_prefix('~') {
Some("") => home(),
Some(rest) if rest.starts_with('/') => home().join(&rest[1..]),
_ => PathBuf::from(text),
}
}
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())),
"scan" | "open" if rest.is_empty() => Err(usage()),
"scan" => Ok(Action::Scan(path(rest))),
"open" => Ok(Action::Open(vec![path(rest)])),
"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),
"zoom" => match rest {
"+" => Ok(Action::Zoom(Zoom::In)),
"-" => Ok(Action::Zoom(Zoom::Out)),
"all" => Ok(Action::Zoom(Zoom::All)),
_ => Err(usage()),
},
"display" => match rest {
"" => Ok(Action::Display(None)),
"envelope" => Ok(Action::Display(Some(Display::Envelope))),
"db" => Ok(Action::Display(Some(Display::Decibels))),
"braille" => Ok(Action::Display(Some(Display::Braille))),
_ => Err(usage()),
},
"write" => nothing(Action::WriteSlices),
"discard" => nothing(Action::DiscardSlices),
"slice" => match first_word(rest) {
("region", "") => Ok(Action::Slice(Slicing::Region)),
("marks", "") => Ok(Action::Slice(Slicing::Marks)),
("onsets", "") => Ok(Action::Slice(Slicing::Onsets(None))),
("onsets", s) => match s.parse::<f32>() {
Ok(s) if (0.0..=1.0).contains(&s) => Ok(Action::Slice(Slicing::Onsets(Some(s)))),
_ => Err("onset sensitivity is 0 to 1".into()),
},
(n, "") if n.parse::<usize>().is_ok() => match n.parse::<usize>() {
Ok(n) if (2..=MAX_SLICES).contains(&n) => Ok(Action::Slice(Slicing::Equal(n))),
_ => Err(format!("slices are 2 to {MAX_SLICES}")),
},
_ => Err(usage()),
},
"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 replace(&mut self, text: &str) {
self.text = text.to_string();
self.settle();
}
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,
}
}
}
pub fn key_rows(keys: &Keymap, view: View) -> Vec<(String, String)> {
let rebound = |key| {
keys.bindings()
.iter()
.any(|b| b.view == Some(view) && b.key == key)
};
let mut rows = Vec::new();
for (scope, heading) in [
(Some(view), format!("in the {} view", view_name(view))),
(None, "in every view".to_string()),
] {
let mut group: Vec<(String, String)> = Vec::new();
let bindings = keys
.bindings()
.iter()
.filter(|b| b.view == scope && (scope.is_some() || !rebound(b.key)));
for b in bindings {
let Some(action) = &b.action else {
continue;
};
let command = format!(":{}", line(action, Some(view)));
match group.iter_mut().find(|(_, c)| *c == command) {
Some((k, _)) => *k = format!("{k} {}", b.key),
None => group.push((b.key.to_string(), command)),
}
}
if !group.is_empty() {
rows.push((heading, String::new()));
rows.extend(group);
}
}
rows
}
pub fn command_rows() -> Vec<(String, String)> {
let mut rows = Vec::new();
let mut group = None;
for c in COMMANDS {
if rows.is_empty() || c.view != group {
group = c.view;
let heading = c.view.map_or("in every view", view_name);
rows.push((heading.to_string(), String::new()));
}
let usage = format!(":{} {}", c.name, c.args);
rows.push((usage.trim_end().to_string(), c.help.to_string()));
}
rows
}