More detailed error message in case of command parse error (#684)
* Refactored `command::parse` * Removed unnecessary duplication in error msg * Renamed `NotEnoughArgs` -> `InsufficientArgs` * Inaccurate var name * Ditch wordy error prefix * Use `split_whitespace` instead of regex * Cleanup unused regex import * `insert` cmd fails fast * Refactor: use `and_then` instead of `unwrap` * Updated `Command::to_string` * Added `Command::basename` * Better err msg when running cmd in unsupported view, fully closes #597 * Sort `match` branches by their order in the enum
This commit is contained in:
760
src/command.rs
760
src/command.rs
@@ -1,4 +1,5 @@
|
||||
use crate::queue::RepeatSetting;
|
||||
use crate::spotify_url::SpotifyUrl;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
@@ -98,6 +99,24 @@ impl fmt::Display for SeekDirection {
|
||||
}
|
||||
}
|
||||
|
||||
#[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")]
|
||||
InsertSource::Clipboard => "".into(),
|
||||
InsertSource::Input(url) => url.to_string(),
|
||||
};
|
||||
write!(f, "{}", repr)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub enum Command {
|
||||
Quit,
|
||||
@@ -130,7 +149,7 @@ pub enum Command {
|
||||
Help,
|
||||
ReloadConfig,
|
||||
Noop,
|
||||
Insert(Option<String>),
|
||||
Insert(InsertSource),
|
||||
NewPlaylist(String),
|
||||
Sort(SortKey, SortDirection),
|
||||
Logout,
|
||||
@@ -141,72 +160,110 @@ pub enum Command {
|
||||
|
||||
impl fmt::Display for Command {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let repr = match self {
|
||||
Command::Noop => "noop".to_string(),
|
||||
Command::Quit => "quit".to_string(),
|
||||
Command::TogglePlay => "playpause".to_string(),
|
||||
Command::Stop => "stop".to_string(),
|
||||
Command::Previous => "previous".to_string(),
|
||||
Command::Next => "next".to_string(),
|
||||
Command::Clear => "clear".to_string(),
|
||||
Command::Queue => "queue".to_string(),
|
||||
Command::PlayNext => "playnext".to_string(),
|
||||
Command::Play => "play".to_string(),
|
||||
Command::UpdateLibrary => "update".to_string(),
|
||||
Command::Save => "save".to_string(),
|
||||
Command::SaveQueue => "save queue".to_string(),
|
||||
Command::Delete => "delete".to_string(),
|
||||
Command::Focus(tab) => format!("focus {}", tab),
|
||||
Command::Seek(direction) => format!("seek {}", direction),
|
||||
Command::VolumeUp(amount) => format!("volup {}", amount),
|
||||
Command::VolumeDown(amount) => format!("voldown {}", amount),
|
||||
Command::Repeat(mode) => {
|
||||
let param = match mode {
|
||||
Some(mode) => format!("{}", mode),
|
||||
None => "".to_string(),
|
||||
};
|
||||
format!("repeat {}", param)
|
||||
}
|
||||
Command::Shuffle(on) => {
|
||||
let param = on.map(|x| if x { "on" } else { "off" });
|
||||
format!("shuffle {}", param.unwrap_or(""))
|
||||
}
|
||||
Command::Share(mode) => format!("share {}", mode),
|
||||
Command::Back => "back".to_string(),
|
||||
Command::Open(mode) => format!("open {}", mode),
|
||||
Command::Goto(mode) => format!("goto {}", mode),
|
||||
Command::Move(mode, MoveAmount::Extreme) => format!(
|
||||
"move {}",
|
||||
match mode {
|
||||
MoveMode::Up => "top",
|
||||
MoveMode::Down => "bottom",
|
||||
MoveMode::Left => "leftmost",
|
||||
MoveMode::Right => "rightmost",
|
||||
_ => "",
|
||||
}
|
||||
),
|
||||
Command::Move(MoveMode::Playing, _) => "move playing".to_string(),
|
||||
Command::Move(mode, MoveAmount::Integer(amount)) => format!("move {} {}", mode, amount),
|
||||
Command::Shift(mode, amount) => format!("shift {} {}", mode, amount.unwrap_or(1)),
|
||||
Command::Search(term) => format!("search {}", term),
|
||||
Command::Jump(mode) => match mode {
|
||||
JumpMode::Previous => "jumpprevious".to_string(),
|
||||
JumpMode::Next => "jumpnext".to_string(),
|
||||
JumpMode::Query(term) => String::from(format!("jump {}", term)),
|
||||
let mut repr_tokens = vec![self.basename().to_owned()];
|
||||
let mut extras_args = match self {
|
||||
Command::Focus(tab) => vec![tab.to_owned()],
|
||||
Command::Seek(direction) => vec![direction.to_string()],
|
||||
Command::VolumeUp(amount) => vec![amount.to_string()],
|
||||
Command::VolumeDown(amount) => vec![amount.to_string()],
|
||||
Command::Repeat(mode) => match mode {
|
||||
Some(mode) => vec![mode.to_string()],
|
||||
None => vec![],
|
||||
},
|
||||
Command::Help => "help".to_string(),
|
||||
Command::ReloadConfig => "reload".to_string(),
|
||||
Command::Insert(_) => "insert".to_string(),
|
||||
Command::NewPlaylist(name) => format!("new playlist {}", name),
|
||||
Command::Sort(key, direction) => format!("sort {} {}", key, direction),
|
||||
Command::Logout => "logout".to_string(),
|
||||
Command::ShowRecommendations(mode) => format!("similar {}", mode),
|
||||
Command::Redraw => "redraw".to_string(),
|
||||
Command::Execute(cmd) => format!("exec {}", cmd),
|
||||
Command::Shuffle(on) => match on {
|
||||
Some(b) => vec![(if *b { "on" } else { "off" }).into()],
|
||||
None => vec![],
|
||||
},
|
||||
Command::Share(mode) => vec![mode.to_string()],
|
||||
Command::Open(mode) => vec![mode.to_string()],
|
||||
Command::Goto(mode) => vec![mode.to_string()],
|
||||
Command::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::Integer(amount)) => vec![mode.to_string(), amount.to_string()],
|
||||
},
|
||||
Command::Shift(mode, amount) => vec![mode.to_string(), amount.unwrap_or(1).to_string()],
|
||||
Command::Search(term) => vec![term.to_owned()],
|
||||
Command::Jump(mode) => match mode {
|
||||
JumpMode::Previous | JumpMode::Next => vec![],
|
||||
JumpMode::Query(term) => vec![term.to_owned()],
|
||||
},
|
||||
Command::Insert(source) => vec![source.to_string()],
|
||||
Command::NewPlaylist(name) => vec![name.to_owned()],
|
||||
Command::Sort(key, direction) => vec![key.to_string(), direction.to_string()],
|
||||
Command::ShowRecommendations(mode) => vec![mode.to_string()],
|
||||
Command::Execute(cmd) => vec![cmd.to_owned()],
|
||||
Command::Quit
|
||||
| Command::TogglePlay
|
||||
| Command::Stop
|
||||
| Command::Previous
|
||||
| Command::Next
|
||||
| Command::Clear
|
||||
| Command::Queue
|
||||
| Command::PlayNext
|
||||
| Command::Play
|
||||
| Command::UpdateLibrary
|
||||
| Command::Save
|
||||
| Command::SaveQueue
|
||||
| Command::Delete
|
||||
| Command::Back
|
||||
| Command::Help
|
||||
| Command::ReloadConfig
|
||||
| Command::Noop
|
||||
| Command::Logout
|
||||
| Command::Redraw => vec![],
|
||||
};
|
||||
// escape the command separator
|
||||
let repr = repr.replace(";", ";;");
|
||||
write!(f, "{}", repr)
|
||||
repr_tokens.append(&mut extras_args);
|
||||
write!(f, "{}", repr_tokens.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn basename(&self) -> &str {
|
||||
match self {
|
||||
Command::Quit => "quit",
|
||||
Command::TogglePlay => "playpause",
|
||||
Command::Stop => "stop",
|
||||
Command::Previous => "previous",
|
||||
Command::Next => "next",
|
||||
Command::Clear => "clear",
|
||||
Command::Queue => "queue",
|
||||
Command::PlayNext => "playnext",
|
||||
Command::Play => "play",
|
||||
Command::UpdateLibrary => "update",
|
||||
Command::Save => "save",
|
||||
Command::SaveQueue => "save queue",
|
||||
Command::Delete => "delete",
|
||||
Command::Focus(_) => "focus",
|
||||
Command::Seek(_) => "seek",
|
||||
Command::VolumeUp(_) => "volup",
|
||||
Command::VolumeDown(_) => "voldown",
|
||||
Command::Repeat(_) => "repeat",
|
||||
Command::Shuffle(_) => "shuffle",
|
||||
Command::Share(_) => "share",
|
||||
Command::Back => "back",
|
||||
Command::Open(_) => "open",
|
||||
Command::Goto(_) => "goto",
|
||||
Command::Move(_, _) => "move",
|
||||
Command::Shift(_, _) => "shift",
|
||||
Command::Search(_) => "search",
|
||||
Command::Jump(JumpMode::Previous) => "jumpprevious",
|
||||
Command::Jump(JumpMode::Next) => "jumpnext",
|
||||
Command::Jump(JumpMode::Query(_)) => "jump",
|
||||
Command::Help => "help",
|
||||
Command::ReloadConfig => "reload",
|
||||
Command::Noop => "noop",
|
||||
Command::Insert(_) => "insert",
|
||||
Command::NewPlaylist(_) => "newplaylist",
|
||||
Command::Sort(_, _) => "sort",
|
||||
Command::Logout => "logout",
|
||||
Command::ShowRecommendations(_) => "similar",
|
||||
Command::Redraw => "redraw",
|
||||
Command::Execute(_) => "exec",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +300,40 @@ fn handle_aliases(input: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Option<Vec<Command>> {
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub enum CommandParseError {
|
||||
NoSuchCommand { cmd: String },
|
||||
InsufficientArgs { cmd: String, hint: Option<String> },
|
||||
BadEnumArg { arg: String, accept: Vec<String> },
|
||||
ArgParseError { arg: String, err: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for CommandParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
use CommandParseError::*;
|
||||
let formatted = match self {
|
||||
NoSuchCommand { cmd } => format!("No such command \"{}\"", cmd),
|
||||
InsufficientArgs { cmd, hint } => {
|
||||
if let Some(hint_str) = hint {
|
||||
format!("\"{}\" requires additional arguments: {}", cmd, hint_str)
|
||||
} else {
|
||||
format!("\"{}\" requires additional arguments", cmd)
|
||||
}
|
||||
}
|
||||
BadEnumArg { arg, accept } => {
|
||||
format!(
|
||||
"Illegal argument \"{}\": supported values are {}",
|
||||
arg,
|
||||
accept.join("|")
|
||||
)
|
||||
}
|
||||
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 {
|
||||
@@ -271,226 +361,362 @@ pub fn parse(input: &str) -> Option<Vec<Command>> {
|
||||
|
||||
let mut commands = vec![];
|
||||
for command_input in command_inputs {
|
||||
let components: Vec<_> = command_input.trim().split(' ').collect();
|
||||
let components: Vec<_> = command_input.split_whitespace().collect();
|
||||
|
||||
let command = handle_aliases(components[0]);
|
||||
let args = components[1..].to_vec();
|
||||
|
||||
use CommandParseError::*;
|
||||
let command = match command {
|
||||
"quit" => Some(Command::Quit),
|
||||
"playpause" => Some(Command::TogglePlay),
|
||||
"stop" => Some(Command::Stop),
|
||||
"previous" => Some(Command::Previous),
|
||||
"next" => Some(Command::Next),
|
||||
"clear" => Some(Command::Clear),
|
||||
"playnext" => Some(Command::PlayNext),
|
||||
"queue" => Some(Command::Queue),
|
||||
"play" => Some(Command::Play),
|
||||
"update" => Some(Command::UpdateLibrary),
|
||||
"delete" => Some(Command::Delete),
|
||||
"back" => Some(Command::Back),
|
||||
"open" => args
|
||||
.get(0)
|
||||
.and_then(|target| match *target {
|
||||
"selected" => Some(TargetMode::Selected),
|
||||
"current" => Some(TargetMode::Current),
|
||||
_ => None,
|
||||
})
|
||||
.map(Command::Open),
|
||||
"jump" => Some(Command::Jump(JumpMode::Query(args.join(" ")))),
|
||||
"jumpnext" => Some(Command::Jump(JumpMode::Next)),
|
||||
"jumpprevious" => Some(Command::Jump(JumpMode::Previous)),
|
||||
"search" => Some(Command::Search(args.join(" "))),
|
||||
"shift" => {
|
||||
let amount = args.get(1).and_then(|amount| amount.parse().ok());
|
||||
|
||||
args.get(0)
|
||||
.and_then(|direction| match *direction {
|
||||
"up" => Some(ShiftMode::Up),
|
||||
"down" => Some(ShiftMode::Down),
|
||||
_ => None,
|
||||
})
|
||||
.map(|mode| Command::Shift(mode, amount))
|
||||
}
|
||||
"move" => {
|
||||
let cmd: Option<Command> = {
|
||||
args.get(0).and_then(|extreme| match *extreme {
|
||||
"top" => Some(Command::Move(MoveMode::Up, MoveAmount::Extreme)),
|
||||
"bottom" => Some(Command::Move(MoveMode::Down, MoveAmount::Extreme)),
|
||||
"leftmost" => Some(Command::Move(MoveMode::Left, MoveAmount::Extreme)),
|
||||
"rightmost" => Some(Command::Move(MoveMode::Right, MoveAmount::Extreme)),
|
||||
"playing" => Some(Command::Move(MoveMode::Playing, MoveAmount::default())),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
|
||||
cmd.or({
|
||||
let amount = args
|
||||
.get(1)
|
||||
.and_then(|amount| amount.parse().ok())
|
||||
.map(MoveAmount::Integer)
|
||||
.unwrap_or_default();
|
||||
|
||||
args.get(0)
|
||||
.and_then(|direction| match *direction {
|
||||
"up" => Some(MoveMode::Up),
|
||||
"down" => Some(MoveMode::Down),
|
||||
"left" => Some(MoveMode::Left),
|
||||
"right" => Some(MoveMode::Right),
|
||||
_ => None,
|
||||
})
|
||||
.map(|mode| Command::Move(mode, amount))
|
||||
})
|
||||
}
|
||||
"goto" => args
|
||||
.get(0)
|
||||
.and_then(|mode| match *mode {
|
||||
"album" => Some(GotoMode::Album),
|
||||
"artist" => Some(GotoMode::Artist),
|
||||
_ => None,
|
||||
})
|
||||
.map(Command::Goto),
|
||||
"share" => args
|
||||
.get(0)
|
||||
.and_then(|target| match *target {
|
||||
"selected" => Some(TargetMode::Selected),
|
||||
"current" => Some(TargetMode::Current),
|
||||
_ => None,
|
||||
})
|
||||
.map(Command::Share),
|
||||
"shuffle" => {
|
||||
let shuffle = args.get(0).and_then(|mode| match *mode {
|
||||
"on" => Some(true),
|
||||
"off" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
Some(Command::Shuffle(shuffle))
|
||||
}
|
||||
"repeat" => {
|
||||
let mode = args.get(0).and_then(|mode| match *mode {
|
||||
"list" | "playlist" | "queue" => Some(RepeatSetting::RepeatPlaylist),
|
||||
"track" | "once" | "single" => Some(RepeatSetting::RepeatTrack),
|
||||
"none" | "off" => Some(RepeatSetting::None),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
Some(Command::Repeat(mode))
|
||||
"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,
|
||||
"save" => match args.get(0).cloned() {
|
||||
Some("queue") => Ok(Command::SaveQueue),
|
||||
Some(arg) => Err(BadEnumArg {
|
||||
arg: arg.into(),
|
||||
accept: vec!["**omit**".into(), "queue".into()],
|
||||
}),
|
||||
None => Ok(Command::Save),
|
||||
}?,
|
||||
"delete" => Command::Delete,
|
||||
"focus" => {
|
||||
let &target = args.get(0).ok_or(InsufficientArgs {
|
||||
cmd: command.into(),
|
||||
hint: Some("queue|search|library".into()),
|
||||
})?;
|
||||
// TODO: this really should be strongly typed
|
||||
Command::Focus(target.into())
|
||||
}
|
||||
"seek" => {
|
||||
let arg = args.join(" ");
|
||||
let first_char = arg.chars().next();
|
||||
let duration_raw = match first_char {
|
||||
Some('+' | '-') => arg.chars().skip(1).collect(),
|
||||
_ => arg.to_string(),
|
||||
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,
|
||||
};
|
||||
duration_raw
|
||||
.parse::<u32>() // accept raw milliseconds for backward compatibility
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
parse_duration::parse(&duration_raw) // accept fancy duration
|
||||
.ok()
|
||||
.and_then(|dur| dur.as_millis().try_into().ok())
|
||||
})
|
||||
.and_then(|unsigned_millis| {
|
||||
match first_char {
|
||||
// handle i32::MAX < unsigned_millis < u32::MAX gracefully
|
||||
Some('+') => {
|
||||
i32::try_from(unsigned_millis)
|
||||
.ok()
|
||||
.map(|unsigned_millis_i32| {
|
||||
SeekDirection::Relative(unsigned_millis_i32)
|
||||
})
|
||||
}
|
||||
Some('-') => {
|
||||
i32::try_from(unsigned_millis)
|
||||
.ok()
|
||||
.map(|unsigned_millis_i32| {
|
||||
SeekDirection::Relative(-unsigned_millis_i32)
|
||||
})
|
||||
}
|
||||
_ => Some(SeekDirection::Absolute(unsigned_millis)),
|
||||
}
|
||||
.map(|direction| Command::Seek(direction))
|
||||
})
|
||||
}
|
||||
"focus" => args
|
||||
.get(0)
|
||||
.map(|target| Command::Focus((*target).to_string())),
|
||||
"save" => args
|
||||
.get(0)
|
||||
.map(|target| match *target {
|
||||
"queue" => Command::SaveQueue,
|
||||
_ => Command::Save,
|
||||
})
|
||||
.or(Some(Command::Save)),
|
||||
"volup" => Some(Command::VolumeUp(
|
||||
args.get(0).and_then(|v| v.parse::<u16>().ok()).unwrap_or(1),
|
||||
)),
|
||||
"voldown" => Some(Command::VolumeDown(
|
||||
args.get(0).and_then(|v| v.parse::<u16>().ok()).unwrap_or(1),
|
||||
)),
|
||||
"help" => Some(Command::Help),
|
||||
"reload" => Some(Command::ReloadConfig),
|
||||
"insert" => {
|
||||
if args.is_empty() {
|
||||
Some(Command::Insert(None))
|
||||
} else {
|
||||
args.get(0)
|
||||
.map(|url| Command::Insert(Some((*url).to_string())))
|
||||
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| ArgParseError {
|
||||
arg: duration_raw.clone(),
|
||||
err: err.to_string(),
|
||||
})
|
||||
.and_then(|dur| {
|
||||
dur.as_millis().try_into().map_err(|_| 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(|millis| SeekDirection::Relative(millis))
|
||||
}
|
||||
Some('-') => i32::try_from(unsigned_millis)
|
||||
.map(|millis| SeekDirection::Relative(-millis)),
|
||||
_ => Ok(SeekDirection::Absolute(unsigned_millis)),
|
||||
}
|
||||
.map_err(|_| ArgParseError {
|
||||
arg: duration_raw,
|
||||
err: "Duration value too large".into(),
|
||||
})?;
|
||||
Command::Seek(seek_direction)
|
||||
}
|
||||
"volup" => {
|
||||
let amount = match args.get(0) {
|
||||
Some(&amount_raw) => {
|
||||
amount_raw.parse::<u16>().map_err(|err| ArgParseError {
|
||||
arg: amount_raw.into(),
|
||||
err: err.to_string(),
|
||||
})?
|
||||
}
|
||||
None => 1,
|
||||
};
|
||||
Command::VolumeUp(amount)
|
||||
}
|
||||
"voldown" => {
|
||||
let amount = match args.get(0) {
|
||||
Some(&amount_raw) => {
|
||||
amount_raw.parse::<u16>().map_err(|err| ArgParseError {
|
||||
arg: amount_raw.into(),
|
||||
err: err.to_string(),
|
||||
})?
|
||||
}
|
||||
None => 1,
|
||||
};
|
||||
Command::VolumeDown(amount)
|
||||
}
|
||||
"repeat" => {
|
||||
let mode = match args.get(0).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(BadEnumArg {
|
||||
arg: arg.into(),
|
||||
accept: vec![
|
||||
"**omit**".into(),
|
||||
"list".into(),
|
||||
"playlist".into(),
|
||||
"queue".into(),
|
||||
"track".into(),
|
||||
"once".into(),
|
||||
"single".into(),
|
||||
"none".into(),
|
||||
"off".into(),
|
||||
],
|
||||
}),
|
||||
None => Ok(None),
|
||||
}?;
|
||||
Command::Repeat(mode)
|
||||
}
|
||||
"shuffle" => {
|
||||
let switch = match args.get(0).cloned() {
|
||||
Some("on") => Ok(Some(true)),
|
||||
Some("off") => Ok(Some(false)),
|
||||
Some(arg) => Err(BadEnumArg {
|
||||
arg: arg.into(),
|
||||
accept: vec!["**omit**".into(), "on".into(), "off".into()],
|
||||
}),
|
||||
None => Ok(None),
|
||||
}?;
|
||||
Command::Shuffle(switch)
|
||||
}
|
||||
"share" => {
|
||||
let &target_mode_raw = args.get(0).ok_or(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(BadEnumArg {
|
||||
arg: target_mode_raw.into(),
|
||||
accept: vec!["selected".into(), "current".into()],
|
||||
}),
|
||||
}?;
|
||||
Command::Share(target_mode)
|
||||
}
|
||||
"back" => Command::Back,
|
||||
"open" => {
|
||||
let &target_mode_raw = args.get(0).ok_or(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(BadEnumArg {
|
||||
arg: target_mode_raw.into(),
|
||||
accept: vec!["selected".into(), "current".into()],
|
||||
}),
|
||||
}?;
|
||||
Command::Open(target_mode)
|
||||
}
|
||||
"goto" => {
|
||||
let &goto_mode_raw = args.get(0).ok_or(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(BadEnumArg {
|
||||
arg: goto_mode_raw.into(),
|
||||
accept: vec!["album".into(), "artist".into()],
|
||||
}),
|
||||
}?;
|
||||
Command::Goto(goto_mode)
|
||||
}
|
||||
"move" => {
|
||||
let &move_mode_raw = args.get(0).ok_or(InsufficientArgs {
|
||||
cmd: command.into(),
|
||||
hint: Some("a direction".into()),
|
||||
})?;
|
||||
let move_mode = {
|
||||
use MoveMode::*;
|
||||
match move_mode_raw {
|
||||
"playing" => Ok(Playing),
|
||||
"top" | "up" => Ok(Up),
|
||||
"bottom" | "down" => Ok(Down),
|
||||
"leftmost" | "left" => Ok(Left),
|
||||
"rightmost" | "right" => Ok(Right),
|
||||
_ => Err(BadEnumArg {
|
||||
arg: move_mode_raw.into(),
|
||||
accept: vec![
|
||||
"playing".into(),
|
||||
"top".into(),
|
||||
"bottom".into(),
|
||||
"leftmost".into(),
|
||||
"rightmost".into(),
|
||||
"up".into(),
|
||||
"down".into(),
|
||||
"left".into(),
|
||||
"right".into(),
|
||||
],
|
||||
}),
|
||||
}?
|
||||
};
|
||||
let move_amount = match move_mode_raw {
|
||||
"playing" => Ok(MoveAmount::default()),
|
||||
"top" | "bottom" | "leftmost" | "rightmost" => Ok(MoveAmount::Extreme),
|
||||
"up" | "down" | "left" | "right" => {
|
||||
let amount = match args.get(1) {
|
||||
Some(&amount_raw) => amount_raw
|
||||
.parse::<i32>()
|
||||
.map(MoveAmount::Integer)
|
||||
.map_err(|err| 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.get(0).ok_or(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(BadEnumArg {
|
||||
arg: shift_dir_raw.into(),
|
||||
accept: vec!["up".into(), "down".into()],
|
||||
}),
|
||||
}?;
|
||||
let amount = match args.get(1) {
|
||||
Some(&amount_raw) => {
|
||||
let amount = amount_raw.parse::<i32>().map_err(|err| 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.get(0).cloned() {
|
||||
#[cfg(feature = "share_clipboard")]
|
||||
Some("") | None => Ok(InsertSource::Clipboard),
|
||||
Some(url) => SpotifyUrl::from_url(url).map(InsertSource::Input).ok_or(
|
||||
ArgParseError {
|
||||
arg: url.into(),
|
||||
err: "Invalid Spotify URL".into(),
|
||||
},
|
||||
),
|
||||
// if clipboard feature is disabled and args is empty
|
||||
#[allow(unreachable_patterns)]
|
||||
None => Err(InsufficientArgs {
|
||||
cmd: command.into(),
|
||||
hint: Some("a Spotify URL".into()),
|
||||
}),
|
||||
}?;
|
||||
Command::Insert(insert_source)
|
||||
}
|
||||
"newplaylist" => {
|
||||
if !args.is_empty() {
|
||||
Some(Command::NewPlaylist(args.join(" ")))
|
||||
Ok(Command::NewPlaylist(args.join(" ")))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Err(InsufficientArgs {
|
||||
cmd: command.into(),
|
||||
hint: Some("a name".into()),
|
||||
})
|
||||
}?
|
||||
}
|
||||
"sort" => {
|
||||
if !args.is_empty() {
|
||||
let sort_key = args.get(0).and_then(|key| match *key {
|
||||
"title" => Some(SortKey::Title),
|
||||
"duration" => Some(SortKey::Duration),
|
||||
"album" => Some(SortKey::Album),
|
||||
"added" => Some(SortKey::Added),
|
||||
"artist" => Some(SortKey::Artist),
|
||||
_ => None,
|
||||
})?;
|
||||
|
||||
let sort_direction = args
|
||||
.get(1)
|
||||
.map(|direction| match *direction {
|
||||
"a" => SortDirection::Ascending,
|
||||
"asc" => SortDirection::Ascending,
|
||||
"ascending" => SortDirection::Ascending,
|
||||
"d" => SortDirection::Descending,
|
||||
"desc" => SortDirection::Descending,
|
||||
"descending" => SortDirection::Descending,
|
||||
_ => SortDirection::Ascending,
|
||||
})
|
||||
.unwrap_or(SortDirection::Ascending);
|
||||
|
||||
Some(Command::Sort(sort_key, sort_direction))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let &key_raw = args.get(0).ok_or(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(BadEnumArg {
|
||||
arg: key_raw.into(),
|
||||
accept: vec![
|
||||
"title".into(),
|
||||
"duration".into(),
|
||||
"album".into(),
|
||||
"added".into(),
|
||||
"artist".into(),
|
||||
],
|
||||
}),
|
||||
}?;
|
||||
let direction = match args.get(1) {
|
||||
Some(&direction_raw) => match direction_raw {
|
||||
"a" | "asc" | "ascending" => Ok(SortDirection::Ascending),
|
||||
"d" | "desc" | "descending" => Ok(SortDirection::Descending),
|
||||
_ => Err(BadEnumArg {
|
||||
arg: direction_raw.into(),
|
||||
accept: vec![
|
||||
"a".into(),
|
||||
"asc".into(),
|
||||
"ascending".into(),
|
||||
"d".into(),
|
||||
"desc".into(),
|
||||
"descending".into(),
|
||||
],
|
||||
}),
|
||||
},
|
||||
None => Ok(SortDirection::Ascending),
|
||||
}?;
|
||||
Command::Sort(key, direction)
|
||||
}
|
||||
"logout" => Some(Command::Logout),
|
||||
"similar" => args
|
||||
.get(0)
|
||||
.and_then(|target| match *target {
|
||||
"selected" => Some(TargetMode::Selected),
|
||||
"current" => Some(TargetMode::Current),
|
||||
_ => None,
|
||||
})
|
||||
.map(Command::ShowRecommendations),
|
||||
"noop" => Some(Command::Noop),
|
||||
"redraw" => Some(Command::Redraw),
|
||||
"exec" => Some(Command::Execute(args.join(" "))),
|
||||
_ => None,
|
||||
"logout" => Command::Logout,
|
||||
"similar" => {
|
||||
let &target_mode_raw = args.get(0).ok_or(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(BadEnumArg {
|
||||
arg: target_mode_raw.into(),
|
||||
accept: vec!["selected".into(), "current".into()],
|
||||
}),
|
||||
}?;
|
||||
Command::ShowRecommendations(target_mode)
|
||||
}
|
||||
"redraw" => Command::Redraw,
|
||||
"exec" => Command::Execute(args.join(" ")),
|
||||
_ => Err(NoSuchCommand {
|
||||
cmd: command.into(),
|
||||
})?, // I'm surprised this compiles lol
|
||||
};
|
||||
commands.push(command?);
|
||||
commands.push(command);
|
||||
}
|
||||
Some(commands)
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::command::{
|
||||
parse, Command, GotoMode, JumpMode, MoveAmount, MoveMode, SeekDirection, ShiftMode, TargetMode,
|
||||
parse, Command, GotoMode, InsertSource, JumpMode, MoveAmount, MoveMode, SeekDirection,
|
||||
ShiftMode, TargetMode,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::events::EventManager;
|
||||
@@ -71,11 +72,17 @@ impl CommandManager {
|
||||
let custom_bindings: Option<HashMap<String, String>> = config.keybindings.clone();
|
||||
|
||||
for (key, commands) in custom_bindings.unwrap_or_default() {
|
||||
if let Some(commands) = parse(&commands) {
|
||||
info!("Custom keybinding: {} -> {:?}", key, commands);
|
||||
kb.insert(key, commands);
|
||||
} else {
|
||||
error!("Invalid command(s) for key {}: {}", key, commands);
|
||||
match parse(&commands) {
|
||||
Ok(cmds) => {
|
||||
info!("Custom keybinding: {} -> {:?}", key, cmds);
|
||||
kb.insert(key, cmds);
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Invalid command(s) for key {}-\"{}\": {}",
|
||||
key, commands, err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,20 +267,27 @@ impl CommandManager {
|
||||
log::info!("Exit code: {}", result);
|
||||
Ok(None)
|
||||
}
|
||||
Command::Jump(_)
|
||||
| Command::Move(_, _)
|
||||
| Command::Shift(_, _)
|
||||
| Command::Play
|
||||
|
||||
Command::Queue
|
||||
| Command::PlayNext
|
||||
| Command::Queue
|
||||
| Command::Play
|
||||
| Command::Save
|
||||
| Command::SaveQueue
|
||||
| Command::Delete
|
||||
| Command::Focus(_)
|
||||
| Command::Share(_)
|
||||
| Command::Back
|
||||
| Command::Open(_)
|
||||
| Command::ShowRecommendations(_)
|
||||
| Command::Goto(_)
|
||||
| Command::Move(_, _)
|
||||
| Command::Shift(_, _)
|
||||
| Command::Jump(_)
|
||||
| Command::Insert(_)
|
||||
| Command::Goto(_) => Ok(None),
|
||||
_ => Err("Unknown Command".into()),
|
||||
| Command::ShowRecommendations(_)
|
||||
| Command::Sort(_, _) => Err(format!(
|
||||
"The command \"{}\" is unsupported in this view",
|
||||
cmd.basename()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +522,12 @@ impl CommandManager {
|
||||
"Shift+Down".into(),
|
||||
vec![Command::Shift(ShiftMode::Down, None)],
|
||||
);
|
||||
kb.insert("Ctrl+v".into(), vec![Command::Insert(None)]);
|
||||
|
||||
#[cfg(feature = "share_clipboard")]
|
||||
kb.insert(
|
||||
"Ctrl+v".into(),
|
||||
vec![Command::Insert(InsertSource::Clipboard)],
|
||||
);
|
||||
|
||||
kb
|
||||
}
|
||||
|
||||
19
src/main.rs
19
src/main.rs
@@ -295,17 +295,18 @@ async fn main() -> Result<(), String> {
|
||||
data.cmd.handle(s, command);
|
||||
}
|
||||
} else {
|
||||
let parsed = command::parse(cmd_without_prefix);
|
||||
if let Some(commands) = parsed {
|
||||
if let Some(data) = s.user_data::<UserData>().cloned() {
|
||||
for cmd in commands {
|
||||
data.cmd.handle(s, cmd);
|
||||
match command::parse(cmd_without_prefix) {
|
||||
Ok(commands) => {
|
||||
if let Some(data) = s.user_data::<UserData>().cloned() {
|
||||
for cmd in commands {
|
||||
data.cmd.handle(s, cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut main = s.find_name::<ui::layout::Layout>("main").unwrap();
|
||||
let err_msg = format!("Failed to parse command(s): \"{}\"", cmd_without_prefix);
|
||||
main.set_result(Err(err_msg));
|
||||
Err(err) => {
|
||||
let mut main = s.find_name::<ui::layout::Layout>("main").unwrap();
|
||||
main.set_result(Err(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
ev.trigger();
|
||||
|
||||
@@ -358,7 +358,7 @@ impl Spotify {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[derive(Copy, Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum UriType {
|
||||
Album,
|
||||
Artist,
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::spotify::UriType;
|
||||
|
||||
use url::{Host, Url};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct SpotifyUrl {
|
||||
pub id: String,
|
||||
pub uri_type: UriType,
|
||||
}
|
||||
|
||||
impl fmt::Display for SpotifyUrl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let type_seg = match self.uri_type {
|
||||
UriType::Album => "album",
|
||||
UriType::Artist => "artist",
|
||||
UriType::Episode => "episode",
|
||||
UriType::Playlist => "playlist",
|
||||
UriType::Show => "show",
|
||||
UriType::Track => "track",
|
||||
};
|
||||
write!(f, "https://open.spotify.com/{}/{}", type_seg, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl SpotifyUrl {
|
||||
fn new(id: &str, uri_type: UriType) -> SpotifyUrl {
|
||||
SpotifyUrl {
|
||||
@@ -22,8 +39,8 @@ impl SpotifyUrl {
|
||||
/// assert_eq!(result.id, "4uLU6hMCjMI75M1A2tKUQC");
|
||||
/// assert_eq!(result.uri_type, URIType::Track);
|
||||
/// ```
|
||||
pub fn from_url(s: &str) -> Option<SpotifyUrl> {
|
||||
let url = Url::parse(s).ok()?;
|
||||
pub fn from_url<S: AsRef<str>>(s: S) -> Option<SpotifyUrl> {
|
||||
let url = Url::parse(s.as_ref()).ok()?;
|
||||
if url.host() != Some(Host::Domain("open.spotify.com")) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use cursive::view::ScrollBase;
|
||||
use cursive::{Cursive, Printer, Rect, Vec2};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::command::{Command, GotoMode, JumpMode, MoveAmount, MoveMode, TargetMode};
|
||||
use crate::command::{Command, GotoMode, InsertSource, JumpMode, MoveAmount, MoveMode, TargetMode};
|
||||
use crate::commands::CommandResult;
|
||||
use crate::library::Library;
|
||||
use crate::model::album::Album;
|
||||
@@ -520,20 +520,15 @@ impl<I: ListItem + Clone> ViewExt for ListView<I> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Insert(url) => {
|
||||
let url = match url.as_ref().map(String::as_str) {
|
||||
Command::Insert(source) => {
|
||||
let url = match source {
|
||||
InsertSource::Input(url) => Some(url.clone()),
|
||||
#[cfg(feature = "share_clipboard")]
|
||||
Some("") | None => read_share().unwrap(),
|
||||
Some(url) => url.to_owned(),
|
||||
// do nothing if clipboard feature is disabled and there is no url provided
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => return Ok(CommandResult::Consumed(None)),
|
||||
InsertSource::Clipboard => read_share().and_then(SpotifyUrl::from_url),
|
||||
};
|
||||
|
||||
let spotify = self.queue.get_spotify();
|
||||
|
||||
let url = SpotifyUrl::from_url(&url);
|
||||
|
||||
if let Some(url) = url {
|
||||
let target: Option<Box<dyn ListItem>> = match url.uri_type {
|
||||
UriType::Track => spotify
|
||||
|
||||
Reference in New Issue
Block a user