* chore(deps): `cargo update` * chore: update to Rust edition 2024 and apply fixes Update the Rust edition and apply changes required in the new edition. Also update the Cargo manifests to reflect the edition change, and ensure changes automatically apply to workspace members in the future. * chore: format all code with `rustfmt` The new Rust edition comes with some new formatting defaults, which need to be applied since the edition was increased. * style: change suggested matches back to `if let` There has been a breaking change in Rust edition 2024 that changed the behavior of `if let` statements slightly. The new behavior is more in line with what users would expect, but could lead to problems in existing code. The automatic edition update therefore changed such `if let` statements to match statements instead. That lead to deeply nested code which was hard to reason about. This changes most of them back to regular `if let` chains, as the new behavior shouldn't cause problems for these cases. --------- Co-authored-by: Henrik Friedrichsen <henrik@affekt.org>
801 lines
31 KiB
Rust
801 lines
31 KiB
Rust
use crate::queue::RepeatSetting;
|
|
use crate::spotify_url::SpotifyUrl;
|
|
use std::collections::HashMap;
|
|
use std::fmt;
|
|
use std::sync::OnceLock;
|
|
|
|
use strum_macros::Display;
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub enum SeekInterval {
|
|
Forward,
|
|
Backwards,
|
|
Custom(usize),
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum TargetMode {
|
|
Current,
|
|
Selected,
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum MoveMode {
|
|
Up,
|
|
Down,
|
|
Left,
|
|
Right,
|
|
Playing,
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum MoveAmount {
|
|
Integer(i32),
|
|
Float(f32),
|
|
Extreme,
|
|
}
|
|
|
|
impl Default for MoveAmount {
|
|
fn default() -> Self {
|
|
Self::Integer(1)
|
|
}
|
|
}
|
|
|
|
/// Keys that can be used to sort songs on.
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum SortKey {
|
|
Title,
|
|
Duration,
|
|
Artist,
|
|
Album,
|
|
Added,
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum SortDirection {
|
|
Ascending,
|
|
Descending,
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum JumpMode {
|
|
Previous,
|
|
Next,
|
|
Query(String),
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum ShiftMode {
|
|
Up,
|
|
Down,
|
|
}
|
|
|
|
#[derive(Display, Clone, Serialize, Deserialize, Debug)]
|
|
#[strum(serialize_all = "lowercase")]
|
|
pub enum GotoMode {
|
|
Album,
|
|
Artist,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub enum SeekDirection {
|
|
Relative(i32),
|
|
Absolute(u32),
|
|
}
|
|
|
|
impl fmt::Display for SeekDirection {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let repr = match self {
|
|
Self::Absolute(pos) => format!("{pos}"),
|
|
Self::Relative(delta) => {
|
|
format!("{}{}", if delta > &0 { "+" } else { "" }, delta)
|
|
}
|
|
};
|
|
write!(f, "{repr}")
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub enum InsertSource {
|
|
#[cfg(feature = "share_clipboard")]
|
|
Clipboard,
|
|
Input(SpotifyUrl),
|
|
}
|
|
|
|
impl fmt::Display for InsertSource {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let repr = match self {
|
|
#[cfg(feature = "share_clipboard")]
|
|
Self::Clipboard => "".into(),
|
|
Self::Input(url) => url.to_string(),
|
|
};
|
|
write!(f, "{repr}")
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub enum Command {
|
|
Quit,
|
|
TogglePlay,
|
|
Stop,
|
|
Previous,
|
|
Next,
|
|
Clear,
|
|
Queue,
|
|
PlayNext,
|
|
Play,
|
|
UpdateLibrary,
|
|
Save,
|
|
SaveCurrent,
|
|
SaveQueue,
|
|
Add,
|
|
AddCurrent,
|
|
Delete,
|
|
Focus(String),
|
|
Seek(SeekDirection),
|
|
VolumeUp(u16),
|
|
VolumeDown(u16),
|
|
Repeat(Option<RepeatSetting>),
|
|
Shuffle(Option<bool>),
|
|
#[cfg(feature = "share_clipboard")]
|
|
Share(TargetMode),
|
|
Back,
|
|
Open(TargetMode),
|
|
Goto(GotoMode),
|
|
Move(MoveMode, MoveAmount),
|
|
Shift(ShiftMode, Option<i32>),
|
|
Search(String),
|
|
Jump(JumpMode),
|
|
Help,
|
|
ReloadConfig,
|
|
Noop,
|
|
Insert(InsertSource),
|
|
NewPlaylist(String),
|
|
Sort(SortKey, SortDirection),
|
|
Logout,
|
|
ShowRecommendations(TargetMode),
|
|
Redraw,
|
|
Execute(String),
|
|
Reconnect,
|
|
}
|
|
|
|
impl fmt::Display for Command {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let mut repr_tokens = vec![self.basename().to_owned()];
|
|
let mut extras_args = match self {
|
|
Self::Focus(tab) => vec![tab.to_owned()],
|
|
Self::Seek(direction) => vec![direction.to_string()],
|
|
Self::VolumeUp(amount) => vec![amount.to_string()],
|
|
Self::VolumeDown(amount) => vec![amount.to_string()],
|
|
Self::Repeat(mode) => match mode {
|
|
Some(mode) => vec![mode.to_string()],
|
|
None => vec![],
|
|
},
|
|
Self::Shuffle(on) => match on {
|
|
Some(b) => vec![(if *b { "on" } else { "off" }).into()],
|
|
None => vec![],
|
|
},
|
|
#[cfg(feature = "share_clipboard")]
|
|
Self::Share(mode) => vec![mode.to_string()],
|
|
Self::Open(mode) => vec![mode.to_string()],
|
|
Self::Goto(mode) => vec![mode.to_string()],
|
|
Self::Move(mode, amount) => match (mode, amount) {
|
|
(MoveMode::Playing, _) => vec!["playing".to_string()],
|
|
(MoveMode::Up, MoveAmount::Extreme) => vec!["top".to_string()],
|
|
(MoveMode::Down, MoveAmount::Extreme) => vec!["bottom".to_string()],
|
|
(MoveMode::Left, MoveAmount::Extreme) => vec!["leftmost".to_string()],
|
|
(MoveMode::Right, MoveAmount::Extreme) => vec!["rightmost".to_string()],
|
|
(mode, MoveAmount::Float(amount)) => vec![mode.to_string(), amount.to_string()],
|
|
(mode, MoveAmount::Integer(amount)) => vec![mode.to_string(), amount.to_string()],
|
|
},
|
|
Self::Shift(mode, amount) => vec![mode.to_string(), amount.unwrap_or(1).to_string()],
|
|
Self::Search(term) => vec![term.to_owned()],
|
|
Self::Jump(mode) => match mode {
|
|
JumpMode::Previous | JumpMode::Next => vec![],
|
|
JumpMode::Query(term) => vec![term.to_owned()],
|
|
},
|
|
Self::Insert(source) => vec![source.to_string()],
|
|
Self::NewPlaylist(name) => vec![name.to_owned()],
|
|
Self::Sort(key, direction) => vec![key.to_string(), direction.to_string()],
|
|
Self::ShowRecommendations(mode) => vec![mode.to_string()],
|
|
Self::Execute(cmd) => vec![cmd.to_owned()],
|
|
Self::Quit
|
|
| Self::TogglePlay
|
|
| Self::Stop
|
|
| Self::Previous
|
|
| Self::Next
|
|
| Self::Clear
|
|
| Self::Queue
|
|
| Self::PlayNext
|
|
| Self::Play
|
|
| Self::UpdateLibrary
|
|
| Self::Save
|
|
| Self::SaveCurrent
|
|
| Self::SaveQueue
|
|
| Self::Add
|
|
| Self::AddCurrent
|
|
| Self::Delete
|
|
| Self::Back
|
|
| Self::Help
|
|
| Self::ReloadConfig
|
|
| Self::Noop
|
|
| Self::Logout
|
|
| Self::Reconnect
|
|
| Self::Redraw => vec![],
|
|
};
|
|
repr_tokens.append(&mut extras_args);
|
|
write!(f, "{}", repr_tokens.join(" "))
|
|
}
|
|
}
|
|
|
|
impl Command {
|
|
pub fn basename(&self) -> &str {
|
|
match self {
|
|
Self::Quit => "quit",
|
|
Self::TogglePlay => "playpause",
|
|
Self::Stop => "stop",
|
|
Self::Previous => "previous",
|
|
Self::Next => "next",
|
|
Self::Clear => "clear",
|
|
Self::Queue => "queue",
|
|
Self::PlayNext => "playnext",
|
|
Self::Play => "play",
|
|
Self::UpdateLibrary => "update",
|
|
Self::Save => "save",
|
|
Self::SaveCurrent => "save current",
|
|
Self::SaveQueue => "save queue",
|
|
Self::Add => "add",
|
|
Self::AddCurrent => "add current",
|
|
Self::Delete => "delete",
|
|
Self::Focus(_) => "focus",
|
|
Self::Seek(_) => "seek",
|
|
Self::VolumeUp(_) => "volup",
|
|
Self::VolumeDown(_) => "voldown",
|
|
Self::Repeat(_) => "repeat",
|
|
Self::Shuffle(_) => "shuffle",
|
|
#[cfg(feature = "share_clipboard")]
|
|
Self::Share(_) => "share",
|
|
Self::Back => "back",
|
|
Self::Open(_) => "open",
|
|
Self::Goto(_) => "goto",
|
|
Self::Move(_, _) => "move",
|
|
Self::Shift(_, _) => "shift",
|
|
Self::Search(_) => "search",
|
|
Self::Jump(JumpMode::Previous) => "jumpprevious",
|
|
Self::Jump(JumpMode::Next) => "jumpnext",
|
|
Self::Jump(JumpMode::Query(_)) => "jump",
|
|
Self::Help => "help",
|
|
Self::ReloadConfig => "reload",
|
|
Self::Noop => "noop",
|
|
Self::Insert(_) => "insert",
|
|
Self::NewPlaylist(_) => "newplaylist",
|
|
Self::Sort(_, _) => "sort",
|
|
Self::Logout => "logout",
|
|
Self::ShowRecommendations(_) => "similar",
|
|
Self::Redraw => "redraw",
|
|
Self::Execute(_) => "exec",
|
|
Self::Reconnect => "reconnect",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn register_aliases(map: &mut HashMap<&str, &str>, cmd: &'static str, names: Vec<&'static str>) {
|
|
for a in names {
|
|
map.insert(a, cmd);
|
|
}
|
|
}
|
|
|
|
fn handle_aliases(input: &str) -> &str {
|
|
// NOTE: There is probably a better way to write this than a static HashMap. The HashMap doesn't
|
|
// improve performance as there's far too few keys, and the use of static doesn't seem good.
|
|
static ALIASES: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
|
|
|
let aliases = ALIASES.get_or_init(|| {
|
|
let mut m = HashMap::new();
|
|
|
|
register_aliases(&mut m, "quit", vec!["q", "x"]);
|
|
register_aliases(
|
|
&mut m,
|
|
"playpause",
|
|
vec!["pause", "toggleplay", "toggleplayback"],
|
|
);
|
|
register_aliases(&mut m, "repeat", vec!["loop"]);
|
|
m
|
|
});
|
|
|
|
if let Some(cmd) = aliases.get(input) {
|
|
handle_aliases(cmd)
|
|
} else {
|
|
input
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub enum CommandParseError {
|
|
NoSuchCommand {
|
|
cmd: String,
|
|
},
|
|
InsufficientArgs {
|
|
cmd: String,
|
|
hint: Option<String>,
|
|
},
|
|
BadEnumArg {
|
|
arg: String,
|
|
accept: Vec<String>,
|
|
optional: bool,
|
|
},
|
|
ArgParseError {
|
|
arg: String,
|
|
err: String,
|
|
},
|
|
}
|
|
|
|
impl fmt::Display for CommandParseError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let formatted = match self {
|
|
Self::NoSuchCommand { cmd } => format!("No such command \"{cmd}\""),
|
|
Self::InsufficientArgs { cmd, hint } => {
|
|
if let Some(hint_str) = hint {
|
|
format!("\"{cmd}\" requires additional arguments: {hint_str}")
|
|
} else {
|
|
format!("\"{cmd}\" requires additional arguments")
|
|
}
|
|
}
|
|
Self::BadEnumArg {
|
|
arg,
|
|
accept,
|
|
optional,
|
|
} => {
|
|
let accept = accept.join("|");
|
|
if *optional {
|
|
format!("Argument \"{arg}\" should be one of {accept} or be omitted")
|
|
} else {
|
|
format!("Argument \"{arg}\" should be one of {accept}")
|
|
}
|
|
}
|
|
Self::ArgParseError { arg, err } => format!("Error with argument \"{arg}\": {err}"),
|
|
};
|
|
write!(f, "{formatted}")
|
|
}
|
|
}
|
|
|
|
pub fn parse(input: &str) -> Result<Vec<Command>, CommandParseError> {
|
|
let mut command_inputs = vec!["".to_string()];
|
|
let mut command_idx = 0;
|
|
enum ParseState {
|
|
Normal,
|
|
SeparatorEncountered,
|
|
}
|
|
let mut parse_state = ParseState::Normal;
|
|
for c in input.chars() {
|
|
let is_separator = c == ';';
|
|
match parse_state {
|
|
ParseState::Normal if is_separator => parse_state = ParseState::SeparatorEncountered,
|
|
ParseState::Normal => command_inputs[command_idx].push(c),
|
|
// ";" is escaped using ";;", so if the previous char already was a ';' push a ';'.
|
|
ParseState::SeparatorEncountered if is_separator => {
|
|
command_inputs[command_idx].push(c);
|
|
parse_state = ParseState::Normal;
|
|
}
|
|
ParseState::SeparatorEncountered => {
|
|
command_idx += 1;
|
|
command_inputs.push(c.to_string());
|
|
parse_state = ParseState::Normal;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut commands = vec![];
|
|
for command_input in command_inputs {
|
|
let components: Vec<_> = command_input.split_whitespace().collect();
|
|
|
|
if let Some((command, args)) = components.split_first() {
|
|
let command = handle_aliases(command);
|
|
use CommandParseError as E;
|
|
let command = match command {
|
|
"quit" => Command::Quit,
|
|
"playpause" => Command::TogglePlay,
|
|
"stop" => Command::Stop,
|
|
"previous" => Command::Previous,
|
|
"next" => Command::Next,
|
|
"clear" => Command::Clear,
|
|
"queue" => Command::Queue,
|
|
"playnext" => Command::PlayNext,
|
|
"play" => Command::Play,
|
|
"update" => Command::UpdateLibrary,
|
|
"add" => match args.first().cloned() {
|
|
Some("current") => Ok(Command::AddCurrent),
|
|
Some(arg) => Err(E::BadEnumArg {
|
|
arg: arg.into(),
|
|
accept: vec!["current".into()],
|
|
optional: true,
|
|
}),
|
|
None => Ok(Command::Add),
|
|
}?,
|
|
"save" => match args.first().cloned() {
|
|
Some("queue") => Ok(Command::SaveQueue),
|
|
Some("current") => Ok(Command::SaveCurrent),
|
|
Some(arg) => Err(E::BadEnumArg {
|
|
arg: arg.into(),
|
|
accept: vec!["queue".into(), "current".into()],
|
|
optional: true,
|
|
}),
|
|
None => Ok(Command::Save),
|
|
}?,
|
|
"delete" => Command::Delete,
|
|
"focus" => {
|
|
let &target = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("queue|search|library".into()),
|
|
})?;
|
|
// TODO: this really should be strongly typed
|
|
Command::Focus(target.into())
|
|
}
|
|
"seek" => {
|
|
if args.is_empty() {
|
|
return Err(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("a duration".into()),
|
|
});
|
|
}
|
|
let arg = args.join(" ");
|
|
let first_char = arg.chars().next();
|
|
let duration_raw = match first_char {
|
|
Some('+' | '-') => {
|
|
arg.chars().skip(1).collect::<String>().trim().into()
|
|
// `trim` is necessary here, otherwise `+1000` -> 1 second, but `+ 1000` -> 1000 seconds
|
|
// this behaviour is inconsistent and could cause confusion
|
|
}
|
|
_ => arg,
|
|
};
|
|
let unsigned_millis = match duration_raw.parse() {
|
|
// accept raw milliseconds
|
|
Ok(millis) => millis,
|
|
Err(_) => parse_duration::parse(&duration_raw) // accept fancy duration
|
|
.map_err(|err| E::ArgParseError {
|
|
arg: duration_raw.clone(),
|
|
err: err.to_string(),
|
|
})
|
|
.and_then(|dur| {
|
|
dur.as_millis().try_into().map_err(|_| E::ArgParseError {
|
|
arg: duration_raw.clone(),
|
|
err: "Duration value too large".into(),
|
|
})
|
|
})?,
|
|
};
|
|
let seek_direction = match first_char {
|
|
// handle i32::MAX < unsigned_millis < u32::MAX gracefully
|
|
Some('+') => i32::try_from(unsigned_millis).map(SeekDirection::Relative),
|
|
Some('-') => i32::try_from(unsigned_millis)
|
|
.map(|millis| SeekDirection::Relative(-millis)),
|
|
_ => Ok(SeekDirection::Absolute(unsigned_millis)),
|
|
}
|
|
.map_err(|_| E::ArgParseError {
|
|
arg: duration_raw,
|
|
err: "Duration value too large".into(),
|
|
})?;
|
|
Command::Seek(seek_direction)
|
|
}
|
|
"volup" => {
|
|
let amount = match args.first() {
|
|
Some(&amount_raw) => {
|
|
amount_raw.parse::<u16>().map_err(|err| E::ArgParseError {
|
|
arg: amount_raw.into(),
|
|
err: err.to_string(),
|
|
})?
|
|
}
|
|
None => 1,
|
|
};
|
|
Command::VolumeUp(amount)
|
|
}
|
|
"voldown" => {
|
|
let amount = match args.first() {
|
|
Some(&amount_raw) => {
|
|
amount_raw.parse::<u16>().map_err(|err| E::ArgParseError {
|
|
arg: amount_raw.into(),
|
|
err: err.to_string(),
|
|
})?
|
|
}
|
|
None => 1,
|
|
};
|
|
Command::VolumeDown(amount)
|
|
}
|
|
"repeat" => {
|
|
let mode = match args.first().cloned() {
|
|
Some("list" | "playlist" | "queue") => {
|
|
Ok(Some(RepeatSetting::RepeatPlaylist))
|
|
}
|
|
Some("track" | "once" | "single") => Ok(Some(RepeatSetting::RepeatTrack)),
|
|
Some("none" | "off") => Ok(Some(RepeatSetting::None)),
|
|
Some(arg) => Err(E::BadEnumArg {
|
|
arg: arg.into(),
|
|
accept: vec![
|
|
"list".into(),
|
|
"playlist".into(),
|
|
"queue".into(),
|
|
"track".into(),
|
|
"once".into(),
|
|
"single".into(),
|
|
"none".into(),
|
|
"off".into(),
|
|
],
|
|
optional: true,
|
|
}),
|
|
None => Ok(None),
|
|
}?;
|
|
Command::Repeat(mode)
|
|
}
|
|
"shuffle" => {
|
|
let switch = match args.first().cloned() {
|
|
Some("on") => Ok(Some(true)),
|
|
Some("off") => Ok(Some(false)),
|
|
Some(arg) => Err(E::BadEnumArg {
|
|
arg: arg.into(),
|
|
accept: vec!["on".into(), "off".into()],
|
|
optional: true,
|
|
}),
|
|
None => Ok(None),
|
|
}?;
|
|
Command::Shuffle(switch)
|
|
}
|
|
#[cfg(feature = "share_clipboard")]
|
|
"share" => {
|
|
let &target_mode_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("selected|current".into()),
|
|
})?;
|
|
let target_mode = match target_mode_raw {
|
|
"selected" => Ok(TargetMode::Selected),
|
|
"current" => Ok(TargetMode::Current),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: target_mode_raw.into(),
|
|
accept: vec!["selected".into(), "current".into()],
|
|
optional: false,
|
|
}),
|
|
}?;
|
|
Command::Share(target_mode)
|
|
}
|
|
"back" => Command::Back,
|
|
"open" => {
|
|
let &target_mode_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("selected|current".into()),
|
|
})?;
|
|
let target_mode = match target_mode_raw {
|
|
"selected" => Ok(TargetMode::Selected),
|
|
"current" => Ok(TargetMode::Current),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: target_mode_raw.into(),
|
|
accept: vec!["selected".into(), "current".into()],
|
|
optional: false,
|
|
}),
|
|
}?;
|
|
Command::Open(target_mode)
|
|
}
|
|
"goto" => {
|
|
let &goto_mode_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("album|artist".into()),
|
|
})?;
|
|
let goto_mode = match goto_mode_raw {
|
|
"album" => Ok(GotoMode::Album),
|
|
"artist" => Ok(GotoMode::Artist),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: goto_mode_raw.into(),
|
|
accept: vec!["album".into(), "artist".into()],
|
|
optional: false,
|
|
}),
|
|
}?;
|
|
Command::Goto(goto_mode)
|
|
}
|
|
"move" => {
|
|
let &move_mode_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("a direction".into()),
|
|
})?;
|
|
let move_mode = {
|
|
use MoveMode as M;
|
|
match move_mode_raw {
|
|
"playing" => Ok(M::Playing),
|
|
"top" | "pageup" | "up" => Ok(M::Up),
|
|
"bottom" | "pagedown" | "down" => Ok(M::Down),
|
|
"leftmost" | "pageleft" | "left" => Ok(M::Left),
|
|
"rightmost" | "pageright" | "right" => Ok(M::Right),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: move_mode_raw.into(),
|
|
accept: vec![
|
|
"playing".into(),
|
|
"top".into(),
|
|
"bottom".into(),
|
|
"leftmost".into(),
|
|
"rightmost".into(),
|
|
"pageup".into(),
|
|
"pagedown".into(),
|
|
"pageleft".into(),
|
|
"pageright".into(),
|
|
"up".into(),
|
|
"down".into(),
|
|
"left".into(),
|
|
"right".into(),
|
|
],
|
|
optional: false,
|
|
}),
|
|
}?
|
|
};
|
|
let move_amount = match move_mode_raw {
|
|
"playing" => Ok(MoveAmount::default()),
|
|
"top" | "bottom" | "leftmost" | "rightmost" => Ok(MoveAmount::Extreme),
|
|
"pageup" | "pagedown" | "pageleft" | "pageright" => {
|
|
let amount = match args.get(1) {
|
|
Some(&amount_raw) => amount_raw
|
|
.parse::<f32>()
|
|
.map(MoveAmount::Float)
|
|
.map_err(|err| E::ArgParseError {
|
|
arg: amount_raw.into(),
|
|
err: err.to_string(),
|
|
})?,
|
|
None => MoveAmount::default(),
|
|
};
|
|
Ok(amount)
|
|
}
|
|
"up" | "down" | "left" | "right" => {
|
|
let amount = match args.get(1) {
|
|
Some(&amount_raw) => amount_raw
|
|
.parse::<i32>()
|
|
.map(MoveAmount::Integer)
|
|
.map_err(|err| E::ArgParseError {
|
|
arg: amount_raw.into(),
|
|
err: err.to_string(),
|
|
})?,
|
|
None => MoveAmount::default(),
|
|
};
|
|
Ok(amount)
|
|
}
|
|
_ => unreachable!(), // already guarded when determining MoveMode
|
|
}?;
|
|
Command::Move(move_mode, move_amount)
|
|
}
|
|
"shift" => {
|
|
let &shift_dir_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("up|down".into()),
|
|
})?;
|
|
let shift_dir = match shift_dir_raw {
|
|
"up" => Ok(ShiftMode::Up),
|
|
"down" => Ok(ShiftMode::Down),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: shift_dir_raw.into(),
|
|
accept: vec!["up".into(), "down".into()],
|
|
optional: false,
|
|
}),
|
|
}?;
|
|
let amount = match args.get(1) {
|
|
Some(&amount_raw) => {
|
|
let amount =
|
|
amount_raw.parse::<i32>().map_err(|err| E::ArgParseError {
|
|
arg: amount_raw.into(),
|
|
err: err.to_string(),
|
|
})?;
|
|
Some(amount)
|
|
}
|
|
None => None,
|
|
};
|
|
Command::Shift(shift_dir, amount)
|
|
}
|
|
"search" => Command::Search(args.join(" ")),
|
|
"jump" => Command::Jump(JumpMode::Query(args.join(" "))),
|
|
"jumpnext" => Command::Jump(JumpMode::Next),
|
|
"jumpprevious" => Command::Jump(JumpMode::Previous),
|
|
"help" => Command::Help,
|
|
"reload" => Command::ReloadConfig,
|
|
"noop" => Command::Noop,
|
|
"insert" => {
|
|
let insert_source = match args.first().cloned() {
|
|
#[cfg(feature = "share_clipboard")]
|
|
Some("") | None => Ok(InsertSource::Clipboard),
|
|
// if clipboard feature is disabled and args is empty
|
|
#[cfg(not(feature = "share_clipboard"))]
|
|
None => Err(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("a Spotify URL".into()),
|
|
}),
|
|
Some(url) => SpotifyUrl::from_url(url).map(InsertSource::Input).ok_or(
|
|
E::ArgParseError {
|
|
arg: url.into(),
|
|
err: "Invalid Spotify URL".into(),
|
|
},
|
|
),
|
|
}?;
|
|
Command::Insert(insert_source)
|
|
}
|
|
"newplaylist" => {
|
|
if !args.is_empty() {
|
|
Ok(Command::NewPlaylist(args.join(" ")))
|
|
} else {
|
|
Err(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("a name".into()),
|
|
})
|
|
}?
|
|
}
|
|
"sort" => {
|
|
let &key_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("a sort key".into()),
|
|
})?;
|
|
let key = match key_raw {
|
|
"title" => Ok(SortKey::Title),
|
|
"duration" => Ok(SortKey::Duration),
|
|
"album" => Ok(SortKey::Album),
|
|
"added" => Ok(SortKey::Added),
|
|
"artist" => Ok(SortKey::Artist),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: key_raw.into(),
|
|
accept: vec![
|
|
"title".into(),
|
|
"duration".into(),
|
|
"album".into(),
|
|
"added".into(),
|
|
"artist".into(),
|
|
],
|
|
optional: false,
|
|
}),
|
|
}?;
|
|
let direction = match args.get(1).copied() {
|
|
Some("a" | "asc" | "ascending") => Ok(SortDirection::Ascending),
|
|
Some("d" | "desc" | "descending") => Ok(SortDirection::Descending),
|
|
Some(direction_raw) => Err(E::BadEnumArg {
|
|
arg: direction_raw.into(),
|
|
accept: vec![
|
|
"a".into(),
|
|
"asc".into(),
|
|
"ascending".into(),
|
|
"d".into(),
|
|
"desc".into(),
|
|
"descending".into(),
|
|
],
|
|
optional: true,
|
|
}),
|
|
None => Ok(SortDirection::Ascending),
|
|
}?;
|
|
Command::Sort(key, direction)
|
|
}
|
|
"logout" => Command::Logout,
|
|
"similar" => {
|
|
let &target_mode_raw = args.first().ok_or(E::InsufficientArgs {
|
|
cmd: command.into(),
|
|
hint: Some("selected|current".into()),
|
|
})?;
|
|
let target_mode = match target_mode_raw {
|
|
"selected" => Ok(TargetMode::Selected),
|
|
"current" => Ok(TargetMode::Current),
|
|
_ => Err(E::BadEnumArg {
|
|
arg: target_mode_raw.into(),
|
|
accept: vec!["selected".into(), "current".into()],
|
|
optional: false,
|
|
}),
|
|
}?;
|
|
Command::ShowRecommendations(target_mode)
|
|
}
|
|
"redraw" => Command::Redraw,
|
|
"exec" => Command::Execute(args.join(" ")),
|
|
"reconnect" => Command::Reconnect,
|
|
_ => {
|
|
return Err(E::NoSuchCommand {
|
|
cmd: command.into(),
|
|
});
|
|
}
|
|
};
|
|
commands.push(command);
|
|
};
|
|
}
|
|
Ok(commands)
|
|
}
|