diff --git a/src/command.rs b/src/command.rs index 4677c91..584bda8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -196,6 +196,8 @@ impl fmt::Display for Command { Command::Logout => "logout".to_string(), Command::ShowRecommendations(mode) => format!("similar {}", mode), }; + // escape the command separator + let repr = repr.replace(";", ";;"); write!(f, "{}", repr) } } @@ -233,206 +235,236 @@ fn handle_aliases(input: &str) -> &str { } } -pub fn parse(input: &str) -> Option { - let components: Vec<_> = input.trim().split(' ').collect(); - - let command = handle_aliases(components[0]); - let args = components[1..].to_vec(); - - 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(" ")))), - "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)) +pub fn parse(input: &str) -> Option> { + 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; + } } - "move" => { - let cmd: Option = { - 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())), + } + + let mut commands = vec![]; + for command_input in command_inputs { + let components: Vec<_> = command_input.trim().split(' ').collect(); + + let command = handle_aliases(components[0]); + let args = components[1..].to_vec(); + + 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, }) - }; - - cmd.or({ - let amount = args - .get(1) - .and_then(|amount| amount.parse().ok()) - .map(MoveAmount::Integer) - .unwrap_or_default(); + .map(Command::Open), + "jump" => Some(Command::Jump(JumpMode::Query(args.join(" ")))), + "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(MoveMode::Up), - "down" => Some(MoveMode::Down), - "left" => Some(MoveMode::Left), - "right" => Some(MoveMode::Right), + "up" => Some(ShiftMode::Up), + "down" => Some(ShiftMode::Down), _ => 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" => Some(RepeatSetting::RepeatTrack), - "none" | "off" => Some(RepeatSetting::None), - _ => None, - }); - - Some(Command::Repeat(mode)) - } - "seek" => args.get(0).and_then(|arg| match arg.chars().next() { - Some(x) if x == '-' || x == '+' => arg - .chars() - .skip(1) - .collect::() - .parse::() - .ok() - .map(|amount| { - Command::Seek(SeekDirection::Relative( - amount - * match x { - '-' => -1, - _ => 1, - }, - )) - }), - _ => arg - .chars() - .collect::() - .parse() - .ok() - .map(|amount| Command::Seek(SeekDirection::Absolute(amount))), - }), - "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::().ok()).unwrap_or(1), - )), - "voldown" => Some(Command::VolumeDown( - args.get(0).and_then(|v| v.parse::().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()))) + .map(|mode| Command::Shift(mode, amount)) } - } - "newplaylist" => { - if !args.is_empty() { - Some(Command::NewPlaylist(args.join(" "))) - } else { - None - } - } - "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, + "move" => { + let cmd: Option = { + 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, }) - .unwrap_or(SortDirection::Ascending); + }; - Some(Command::Sort(sort_key, sort_direction)) - } else { - 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)) + }) } - } - "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), - _ => None, + "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" => Some(RepeatSetting::RepeatTrack), + "none" | "off" => Some(RepeatSetting::None), + _ => None, + }); + + Some(Command::Repeat(mode)) + } + "seek" => args.get(0).and_then(|arg| match arg.chars().next() { + Some(x) if x == '-' || x == '+' => arg + .chars() + .skip(1) + .collect::() + .parse::() + .ok() + .map(|amount| { + Command::Seek(SeekDirection::Relative( + amount + * match x { + '-' => -1, + _ => 1, + }, + )) + }), + _ => arg + .chars() + .collect::() + .parse() + .ok() + .map(|amount| Command::Seek(SeekDirection::Absolute(amount))), + }), + "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::().ok()).unwrap_or(1), + )), + "voldown" => Some(Command::VolumeDown( + args.get(0).and_then(|v| v.parse::().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()))) + } + } + "newplaylist" => { + if !args.is_empty() { + Some(Command::NewPlaylist(args.join(" "))) + } else { + None + } + } + "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 + } + } + "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), + _ => None, + }; + commands.push(command?); } + Some(commands) } diff --git a/src/commands.rs b/src/commands.rs index f18d573..90fb425 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -33,7 +33,7 @@ pub enum CommandResult { pub struct CommandManager { aliases: HashMap, - bindings: RefCell>, + bindings: RefCell>>, spotify: Spotify, queue: Arc, library: Arc, @@ -61,7 +61,7 @@ impl CommandManager { } } - pub fn get_bindings(config: Arc) -> HashMap { + pub fn get_bindings(config: Arc) -> HashMap> { let config = config.values(); let mut kb = if config.default_keybindings.unwrap_or(true) { Self::default_keybindings() @@ -70,12 +70,12 @@ impl CommandManager { }; let custom_bindings: Option> = config.keybindings.clone(); - for (key, command) in custom_bindings.unwrap_or_default() { - if let Some(command) = parse(&command) { - info!("Custom keybinding: {} -> {:?}", key, command); - kb.insert(key, command); + 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 for key {}: {}", key, command); + error!("Invalid command(s) for key {}: {}", key, commands); } } @@ -309,11 +309,13 @@ impl CommandManager { &self, cursive: &mut Cursive, event: E, - command: Command, + commands: Vec, ) { cursive.add_global_callback(event, move |s| { if let Some(data) = s.user_data::().cloned() { - data.cmd.handle(s, command.clone()); + for command in commands.clone().into_iter() { + data.cmd.handle(s, command); + } } }); } @@ -340,133 +342,160 @@ impl CommandManager { } } - fn default_keybindings() -> HashMap { + fn default_keybindings() -> HashMap> { let mut kb = HashMap::new(); - kb.insert("q".into(), Command::Quit); - kb.insert("Shift+p".into(), Command::TogglePlay); - kb.insert("Shift+u".into(), Command::UpdateLibrary); - kb.insert("Shift+s".into(), Command::Stop); - kb.insert("<".into(), Command::Previous); - kb.insert(">".into(), Command::Next); - kb.insert("c".into(), Command::Clear); - kb.insert("Space".into(), Command::Queue); - kb.insert(".".into(), Command::PlayNext); - kb.insert("Enter".into(), Command::Play); - kb.insert("n".into(), Command::Jump(JumpMode::Next)); - kb.insert("Shift+n".into(), Command::Jump(JumpMode::Previous)); - kb.insert("s".into(), Command::Save); - kb.insert("Ctrl+s".into(), Command::SaveQueue); - kb.insert("d".into(), Command::Delete); - kb.insert("f".into(), Command::Seek(SeekDirection::Relative(1000))); - kb.insert("b".into(), Command::Seek(SeekDirection::Relative(-1000))); + kb.insert("q".into(), vec![Command::Quit]); + kb.insert("Shift+p".into(), vec![Command::TogglePlay]); + kb.insert("Shift+u".into(), vec![Command::UpdateLibrary]); + kb.insert("Shift+s".into(), vec![Command::Stop]); + kb.insert("<".into(), vec![Command::Previous]); + kb.insert(">".into(), vec![Command::Next]); + kb.insert("c".into(), vec![Command::Clear]); + kb.insert( + "Space".into(), + vec![ + Command::Queue, + Command::Move(MoveMode::Down, Default::default()), + ], + ); + kb.insert( + ".".into(), + vec![ + Command::PlayNext, + Command::Move(MoveMode::Down, Default::default()), + ], + ); + kb.insert("Enter".into(), vec![Command::Play]); + kb.insert("n".into(), vec![Command::Jump(JumpMode::Next)]); + kb.insert("Shift+n".into(), vec![Command::Jump(JumpMode::Previous)]); + kb.insert("s".into(), vec![Command::Save]); + kb.insert("Ctrl+s".into(), vec![Command::SaveQueue]); + kb.insert("d".into(), vec![Command::Delete]); + kb.insert( + "f".into(), + vec![Command::Seek(SeekDirection::Relative(1000))], + ); + kb.insert( + "b".into(), + vec![Command::Seek(SeekDirection::Relative(-1000))], + ); kb.insert( "Shift+f".into(), - Command::Seek(SeekDirection::Relative(10000)), + vec![Command::Seek(SeekDirection::Relative(10000))], ); kb.insert( "Shift+b".into(), - Command::Seek(SeekDirection::Relative(-10000)), + vec![Command::Seek(SeekDirection::Relative(-10000))], ); - kb.insert("+".into(), Command::VolumeUp(1)); - kb.insert("]".into(), Command::VolumeUp(5)); - kb.insert("-".into(), Command::VolumeDown(1)); - kb.insert("[".into(), Command::VolumeDown(5)); + kb.insert("+".into(), vec![Command::VolumeUp(1)]); + kb.insert("]".into(), vec![Command::VolumeUp(5)]); + kb.insert("-".into(), vec![Command::VolumeDown(1)]); + kb.insert("[".into(), vec![Command::VolumeDown(5)]); - kb.insert("r".into(), Command::Repeat(None)); - kb.insert("z".into(), Command::Shuffle(None)); - kb.insert("x".into(), Command::Share(TargetMode::Selected)); - kb.insert("Shift+x".into(), Command::Share(TargetMode::Current)); + kb.insert("r".into(), vec![Command::Repeat(None)]); + kb.insert("z".into(), vec![Command::Shuffle(None)]); + kb.insert("x".into(), vec![Command::Share(TargetMode::Selected)]); + kb.insert("Shift+x".into(), vec![Command::Share(TargetMode::Current)]); - kb.insert("F1".into(), Command::Focus("queue".into())); - kb.insert("F2".into(), Command::Focus("search".into())); - kb.insert("F3".into(), Command::Focus("library".into())); + kb.insert("F1".into(), vec![Command::Focus("queue".into())]); + kb.insert("F2".into(), vec![Command::Focus("search".into())]); + kb.insert("F3".into(), vec![Command::Focus("library".into())]); #[cfg(feature = "cover")] - kb.insert("F8".into(), Command::Focus("cover".into())); - kb.insert("?".into(), Command::Help); - kb.insert("Backspace".into(), Command::Back); + kb.insert("F8".into(), vec![Command::Focus("cover".into())]); + kb.insert("?".into(), vec![Command::Help]); + kb.insert("Backspace".into(), vec![Command::Back]); - kb.insert("o".into(), Command::Open(TargetMode::Selected)); - kb.insert("Shift+o".into(), Command::Open(TargetMode::Current)); - kb.insert("a".into(), Command::Goto(GotoMode::Album)); - kb.insert("A".into(), Command::Goto(GotoMode::Artist)); + kb.insert("o".into(), vec![Command::Open(TargetMode::Selected)]); + kb.insert("Shift+o".into(), vec![Command::Open(TargetMode::Current)]); + kb.insert("a".into(), vec![Command::Goto(GotoMode::Album)]); + kb.insert("A".into(), vec![Command::Goto(GotoMode::Artist)]); kb.insert( "m".into(), - Command::ShowRecommendations(TargetMode::Selected), + vec![Command::ShowRecommendations(TargetMode::Selected)], ); kb.insert( "M".into(), - Command::ShowRecommendations(TargetMode::Current), + vec![Command::ShowRecommendations(TargetMode::Current)], ); - kb.insert("Up".into(), Command::Move(MoveMode::Up, Default::default())); + kb.insert( + "Up".into(), + vec![Command::Move(MoveMode::Up, Default::default())], + ); kb.insert( "p".into(), - Command::Move(MoveMode::Playing, Default::default()), + vec![Command::Move(MoveMode::Playing, Default::default())], ); kb.insert( "Down".into(), - Command::Move(MoveMode::Down, Default::default()), + vec![Command::Move(MoveMode::Down, Default::default())], ); kb.insert( "Left".into(), - Command::Move(MoveMode::Left, Default::default()), + vec![Command::Move(MoveMode::Left, Default::default())], ); kb.insert( "Right".into(), - Command::Move(MoveMode::Right, Default::default()), + vec![Command::Move(MoveMode::Right, Default::default())], ); kb.insert( "PageUp".into(), - Command::Move(MoveMode::Up, MoveAmount::Integer(5)), + vec![Command::Move(MoveMode::Up, MoveAmount::Integer(5))], ); kb.insert( "PageDown".into(), - Command::Move(MoveMode::Down, MoveAmount::Integer(5)), + vec![Command::Move(MoveMode::Down, MoveAmount::Integer(5))], ); kb.insert( "Home".into(), - Command::Move(MoveMode::Up, MoveAmount::Extreme), + vec![Command::Move(MoveMode::Up, MoveAmount::Extreme)], ); kb.insert( "End".into(), - Command::Move(MoveMode::Down, MoveAmount::Extreme), + vec![Command::Move(MoveMode::Down, MoveAmount::Extreme)], + ); + kb.insert( + "k".into(), + vec![Command::Move(MoveMode::Up, Default::default())], ); - kb.insert("k".into(), Command::Move(MoveMode::Up, Default::default())); kb.insert( "j".into(), - Command::Move(MoveMode::Down, Default::default()), + vec![Command::Move(MoveMode::Down, Default::default())], ); kb.insert( "h".into(), - Command::Move(MoveMode::Left, Default::default()), + vec![Command::Move(MoveMode::Left, Default::default())], ); kb.insert( "l".into(), - Command::Move(MoveMode::Right, Default::default()), + vec![Command::Move(MoveMode::Right, Default::default())], ); kb.insert( "Ctrl+p".into(), - Command::Move(MoveMode::Up, Default::default()), + vec![Command::Move(MoveMode::Up, Default::default())], ); kb.insert( "Ctrl+n".into(), - Command::Move(MoveMode::Down, Default::default()), + vec![Command::Move(MoveMode::Down, Default::default())], ); kb.insert( "Ctrl+a".into(), - Command::Move(MoveMode::Left, Default::default()), + vec![Command::Move(MoveMode::Left, Default::default())], ); kb.insert( "Ctrl+e".into(), - Command::Move(MoveMode::Right, Default::default()), + vec![Command::Move(MoveMode::Right, Default::default())], ); - kb.insert("Shift+Up".into(), Command::Shift(ShiftMode::Up, None)); - kb.insert("Shift+Down".into(), Command::Shift(ShiftMode::Down, None)); - kb.insert("Ctrl+v".into(), Command::Insert(None)); + kb.insert("Shift+Up".into(), vec![Command::Shift(ShiftMode::Up, None)]); + kb.insert( + "Shift+Down".into(), + vec![Command::Shift(ShiftMode::Down, None)], + ); + kb.insert("Ctrl+v".into(), vec![Command::Insert(None)]); kb } diff --git a/src/main.rs b/src/main.rs index 8d4e12c..140d927 100644 --- a/src/main.rs +++ b/src/main.rs @@ -288,13 +288,15 @@ async fn main() -> Result<(), String> { } } else { let parsed = command::parse(cmd_without_prefix); - if let Some(parsed) = parsed { + if let Some(commands) = parsed { if let Some(data) = s.user_data::().cloned() { - data.cmd.handle(s, parsed) + for cmd in commands { + data.cmd.handle(s, cmd); + } } } else { let mut main = s.find_name::("main").unwrap(); - let err_msg = format!("Unknown command: \"{}\"", cmd_without_prefix); + let err_msg = format!("Failed to parse command(s): \"{}\"", cmd_without_prefix); main.set_result(Err(err_msg)); } } diff --git a/src/ui/help.rs b/src/ui/help.rs index 9266651..c8d51b3 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -17,7 +17,7 @@ pub struct HelpView { } impl HelpView { - pub fn new(bindings: HashMap) -> HelpView { + pub fn new(bindings: HashMap>) -> HelpView { let mut text = StyledString::styled("Keybindings\n\n", Effect::Bold); let note = format!( @@ -30,8 +30,16 @@ impl HelpView { keys.sort(); for key in keys { - let command = &bindings[key]; - let binding = format!("{} -> {}\n", key, command); + let commands = &bindings[key]; + let binding = format!( + "{} -> {}\n", + key, + commands + .iter() + .map(|c| c.to_string()) + .collect::>() + .join("; ") + ); text.append(binding); }