From 4570de2da70d49082864a1874063aa4efecfed9d Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Tue, 3 Nov 2026 14:04:44 +0100 Subject: [PATCH] feat(config): configurable keybinds via [keys] in config.toml, add cookies_from_browser option Mainview keys can now be remapped per action. vim keys remain the default and anything left uset keeps its default. Supports ctrl+ keys, named keys and multi-key sequences like "gg". Invalid entries show a warning on start :3 You're now able to use cookies from your browser to fetch the songs. See config.toml. --- README.md | 48 ++++++- src/app.rs | 196 +++++++++++++++------------- src/config.rs | 35 ++++- src/keys.rs | 353 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/ui.rs | 4 +- 6 files changed, 545 insertions(+), 92 deletions(-) create mode 100644 src/keys.rs diff --git a/README.md b/README.md index 4ec6a77..411fc69 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Search, queue and play music with no account, no login and no browser. - **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history - **Picks up where you left off** - your queue and position are restored the next time you start Drift - **Desktop integration** - media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich precense -- **Configurable** - volume, seek step, shuffle/repeat defaults, accent color and more in a single TOML file +- **Configurable** - volume, seek step, shuffle/repeat defaults, accent color, custom keybinds and more in a single TOML file ## Installation @@ -116,12 +116,16 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `G` | song menu | | `ga` | go to the artist | | `gs` | see similar songs | +| `gb` | go to the album | +| `gB` | save the album as a playlist | | `L` | toggle the lyrics panel | | `F` | fullscreen lyrics | | `/` `:` | search / command line | | `?` | help | | `q` | quit | +These are the defaults. See [Custom keys](#custom-keys) to change them. + ### Commands | Command | Action | @@ -179,6 +183,48 @@ cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp whe By default Drift streams songs, so playback starts right away even for hour long tracks. Set `download = true` to cache songs on disk first (this also prefetches the next song). If YouTube asks you to sign in or playback fails, set `cookies_from_browser` to the browser where youre logged into YouTube. If you alr have a `config.toml`, add the `[audio]` section by hand, since Drift never rewrites it. +### Custom keys + +The keys in the table above are the defaults. To change them add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys; everything you leave out keeps its default. + +```toml +[keys] +quit = "Q" +move_down = ["j", "down", "ctrl+n"] +move_up = ["k", "up", "ctrl+p"] +go_top = ["gg", "home"] +shuffle = [] # an empty list unbinds the action +``` + +A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `menu`, `f1` to `f12`), `ctrl+` plus a key, or a sequence like `"gg"` or `"ctrl+x g"`. Counts (`5j`) keep working on any binding. If you bind a key that is a default of another action, the other action loses it. A mistake in `[keys]` shows a message at startup and falls back to the default keys. + +| Action | Default keys | +| --- | --- | +| `move_down` `move_up` | `j` `down` / `k` `up` | +| `half_page_down` `half_page_up` | `ctrl+d` / `ctrl+u` | +| `page_down` `page_up` | `ctrl+f` `pagedown` / `ctrl+b` `pageup` | +| `go_top` `go_bottom` | `gg` `home` / `ge` `end` | +| `next_view` `prev_view` | `tab` / `backtab` | +| `view_results` `view_queue` `view_playlists` | `gr` / `gq` / `gp` | +| `activate` | `enter` | +| `pause` | `space` | +| `next` `prev` | `n` `>` / `N` `<` | +| `seek_back` `seek_forward` | `h` `left` / `l` `right` | +| `volume_up` `volume_down` | `+` `=` / `-` `_` | +| `add` `add_all` `play_all` | `a` / `A` / `P` | +| `delete` | `x` `delete` `dd` | +| `move_item_down` `move_item_up` | `J` / `K` | +| `shuffle` `repeat` | `s` / `r` | +| `menu` | `G` `menu` | +| `go_artist` `go_similar` `go_album` `save_album` | `ga` / `gs` / `gb` / `gB` | +| `lyrics_panel` `lyrics` | `L` / `F` | +| `search` `command` | `/` / `:` | +| `help` | `?` | +| `quit` | `q` | +| `back` | `backspace` `esc` | + +In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable; popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults. + Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. ## Contributing diff --git a/src/app.rs b/src/app.rs index 5dd2f32..d8fdade 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::config::{self, Audio, Config}; use crate::lyrics::{self, Lyrics}; use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::discord::Discord; +use crate::keys::{Action, Key, Keymap, Lookup}; use crate::mpris::{Cmd, Mpris}; use crate::player::{self, Player, PlayerEvent}; use crate::session::Session; @@ -204,7 +205,7 @@ pub struct App { history: Vec, hist_pos: Option, pub count: Option, - pub pending: Option, + pub pending: Vec, pub play_state: PlayState, pub position: f64, @@ -232,6 +233,7 @@ pub struct App { mpris: Option, discord: Option, owns_session: bool, + keymap: Keymap, seek_step: f64, volume_step: f64, autoplay: bool, @@ -307,7 +309,8 @@ impl App { history: Vec::new(), hist_pos: None, count: None, - pending: None, + pending: Vec::new(), + keymap: cfg.keymap.clone(), play_state: PlayState::Stopped, position: 0.0, duration: 0.0, @@ -541,122 +544,103 @@ impl App { } fn key_normal(&mut self, key: KeyEvent) { - let ctrl = is_ctrl(&key); + let key = Key::from(key); - if let Some(p) = self.pending.take() { - let n = self.count.take(); - if let KeyCode::Char(c) = key.code { - match (p, c) { - ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), - ('g', 'e') => { - self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) - } - ('g', 'r') => self.set_view(View::Results), - ('g', 'q') => self.set_view(View::Queue), - ('g', 'p') => self.set_view(View::Playlists), - ('d', 'd') => self.delete_selected(), - ('g', 'a') => self.go_to_artist(), - ('g', 's') => self.go_similar(), - ('g', 'b') => self.go_album(), - ('g', 'B') => self.save_album(None), - _ => {} - } - } - return; - } - - if let KeyCode::Char(c @ '0'..='9') = key.code { - if !ctrl && !(c == '0' && self.count.is_none()) { - let d = c as usize - '0' as usize; + if self.pending.is_empty() { + if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) { self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); return; } } - let n = self.count.take(); + + self.pending.push(key); + match self.keymap.lookup(&self.pending) { + Lookup::Pending => {} + Lookup::Run(action) => { + self.pending.clear(); + let n = self.count.take(); + self.run_action(action, n); + } + Lookup::Nothing => { + self.pending.clear(); + self.count = None; + } + } + } + + fn run_action(&mut self, action: Action, n: Option) { let times = n.unwrap_or(1); if self.view == View::Playlists { - match key.code { - KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => { + match action { + Action::SeekForward if !self.in_playlist() => { return self.open_selected_playlist(); } - KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc - if self.in_playlist() && !ctrl => - { + Action::SeekBack | Action::Back if self.in_playlist() => { return self.close_playlist(); } _ => {} } } - match key.code { - KeyCode::Char('d') if ctrl => self.move_sel(10), - KeyCode::Char('u') if ctrl => self.move_sel(-10), - KeyCode::Char('f') if ctrl => self.move_sel(20), - KeyCode::Char('b') if ctrl => self.move_sel(-20), - KeyCode::PageDown => self.move_sel(20), - KeyCode::PageUp => self.move_sel(-20), - KeyCode::Home => self.set_selected(0), - KeyCode::End => self.set_selected(usize::MAX), + match action { + Action::HalfPageDown => self.move_sel(10), + Action::HalfPageUp => self.move_sel(-10), + Action::PageDown => self.move_sel(20), + Action::PageUp => self.move_sel(-20), + Action::MoveDown => self.move_sel(times as isize), + Action::MoveUp => self.move_sel(-(times as isize)), + Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + Action::GoBottom => { + self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + } + Action::Menu => self.open_menu(), + Action::Delete => self.delete_selected(), - KeyCode::Char('j') | KeyCode::Down => self.move_sel(times as isize), - KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)), - KeyCode::Char('g') => { - self.pending = Some('g'); - self.count = n; - } - KeyCode::Char('G') | KeyCode::Menu => self.open_menu(), - KeyCode::Char('d') => { - if matches!(self.view, View::Queue | View::Playlists) { - self.pending = Some('d'); - } - } - KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(), + Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)), + Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)), + Action::ViewResults => self.set_view(View::Results), + Action::ViewQueue => self.set_view(View::Queue), + Action::ViewPlaylists => self.set_view(View::Playlists), - KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)), - KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)), - - KeyCode::Enter => self.activate(), - KeyCode::Char(' ') => self.toggle_pause(), - KeyCode::Char('n') | KeyCode::Char('>') => self.next(), - KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), - KeyCode::Char('h') | KeyCode::Left => { - self.seek(Amount::Delta(-self.seek_step * times as f64)) - } - KeyCode::Char('l') | KeyCode::Right => { - self.seek(Amount::Delta(self.seek_step * times as f64)) - } - KeyCode::Char('+') | KeyCode::Char('=') => { - self.set_volume(Amount::Delta(self.volume_step * times as f64)) - } - KeyCode::Char('-') | KeyCode::Char('_') => { + Action::Activate => self.activate(), + Action::Pause => self.toggle_pause(), + Action::Next => self.next(), + Action::Prev => self.prev(), + Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)), + Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)), + Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)), + Action::VolumeDown => { self.set_volume(Amount::Delta(-self.volume_step * times as f64)) } - KeyCode::Char('a') => self.add_selected(), - KeyCode::Char('A') => self.add_all_results(), - KeyCode::Char('P') => self.play_all_results(), - KeyCode::Char('J') => self.move_item(1), - KeyCode::Char('K') => self.move_item(-1), - KeyCode::Char('s') => self.toggle_shuffle(), - KeyCode::Char('r') => self.cycle_repeat(), + Action::Add => self.add_selected(), + Action::AddAll => self.add_all_results(), + Action::PlayAll => self.play_all_results(), + Action::MoveItemDown => self.move_item(1), + Action::MoveItemUp => self.move_item(-1), + Action::Shuffle => self.toggle_shuffle(), + Action::Repeat => self.cycle_repeat(), - KeyCode::Char('/') => { + Action::Search => { self.cmd_buf.clear(); self.cmd_search = true; self.enter_command_mode(); } - KeyCode::Char(':') => { + Action::Command => { self.cmd_buf.clear(); self.cmd_search = false; self.enter_command_mode(); } - KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel, - KeyCode::Char('F') => self.open_lyrics(), - KeyCode::Char('?') => self.open_help(), - KeyCode::Char('q') => self.should_quit = true, - KeyCode::Esc => self.status = None, - _ => {} + Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel, + Action::Lyrics => self.open_lyrics(), + Action::GoArtist => self.go_to_artist(), + Action::GoSimilar => self.go_similar(), + Action::GoAlbum => self.go_album(), + Action::SaveAlbum => self.save_album(None), + Action::Help => self.open_help(), + Action::Quit => self.should_quit = true, + Action::Back => self.status = None, } } @@ -2536,6 +2520,7 @@ impl App { #[cfg(test)] mod tests { use super::*; + use crate::keys::Keys; use std::sync::mpsc; fn track(id: &str, title: &str) -> Track { @@ -2652,6 +2637,40 @@ mod tests { app.shuffle = false; } + fn check_custom_keys(app: &mut App) { + let custom = [ + ("move_down", Keys::Many(vec!["e".into()])), + ("go_top", Keys::One("zz".into())), + ("quit", Keys::One("Q".into())), + ]; + let custom = custom.into_iter().map(|(k, v)| (k.into(), v)).collect(); + app.keymap = Keymap::build(&custom).unwrap(); + + app.results = (0..5).map(|i| track(&format!("{i:011}"), "T")).collect(); + app.view = View::Results; + app.states[View::Results.idx()].select(Some(0)); + + press(app, KeyCode::Char('j')); + assert_eq!(app.selected(), 0); + press(app, KeyCode::Char('e')); + assert_eq!(app.selected(), 1); + press(app, KeyCode::Char('2')); + press(app, KeyCode::Char('e')); + assert_eq!(app.selected(), 3); + press(app, KeyCode::Char('z')); + assert!(app.count.is_none() && !app.pending.is_empty()); + press(app, KeyCode::Char('z')); + assert_eq!(app.selected(), 0); + assert!(app.pending.is_empty()); + + press(app, KeyCode::Char('q')); + assert!(!app.should_quit); + press(app, KeyCode::Char('Q')); + assert!(app.should_quit); + app.should_quit = false; + app.keymap = Keymap::default(); + } + #[test] fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); @@ -2742,6 +2761,7 @@ mod tests { check_artist_popup(&mut app); check_paging(&mut app); + check_custom_keys(&mut app); app.run_command("remove"); assert_eq!(app.queue.len(), 1); diff --git a/src/config.rs b/src/config.rs index afaf6ee..cb92b38 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::str::FromStr; @@ -7,6 +8,7 @@ use ratatui::style::Color; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer}; +use crate::keys::{Keymap, Keys}; use crate::model::{RepeatMode, Track}; use crate::session::Session; use crate::store::{write_atomic, Store}; @@ -144,6 +146,9 @@ pub struct Config { pub ui: Ui, pub discord: Discord, pub audio: Audio, + pub keys: HashMap, + #[serde(skip)] + pub keymap: Keymap, } impl Config { @@ -216,6 +221,17 @@ download_max_minutes = {dl_max} # Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\", # \"firefox:my-profile\". Empty means off. Close the browser if reading fails. cookies_from_browser = \"{cookies}\" + +[keys] +# Remap keys here. Each line sets an action to one key or a list of keys and +# replaces that action's default keys. Everything you leave out keeps its vim +# default. See the README for the list of actions. +# Write keys as a character (\"x\", \"G\"), a name (space, enter, esc, tab, backtab, +# backspace, delete, home, end, pageup, pagedown, up, down, left, right, f1-f12), +# ctrl+key (\"ctrl+d\") or a sequence (\"gg\", \"ctrl+x g\"). An empty list unbinds. +# quit = \"Q\" +# move_down = [\"j\", \"down\"] +# go_top = [\"gg\", \"home\"] ", volume = p.volume, shuffle = p.shuffle, @@ -257,7 +273,14 @@ cookies_from_browser = \"{cookies}\" match toml::from_str::(&text) { Ok(mut c) => { c.playback.sanitize(); - (c, None) + let warning = match Keymap::build(&c.keys) { + Ok(keymap) => { + c.keymap = keymap; + None + } + Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")), + }; + (c, warning) } Err(e) => { let bak = path.with_extension("toml.bak"); @@ -404,6 +427,16 @@ mod tests { assert_eq!(c.ui.accent, Color::Blue); } + #[test] + fn keys_section() { + let c: Config = toml::from_str("").unwrap(); + assert!(c.keys.is_empty()); + let c: Config = + toml::from_str("[keys]\nquit = \"Q\"\nmove_down = [\"e\", \"down\"]").unwrap(); + assert!(Keymap::build(&c.keys).is_ok()); + assert_eq!(c.keys.len(), 2); + } + #[test] fn audio_section() { let c: Config = toml::from_str("").unwrap(); diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..50a997a --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,353 @@ +use std::collections::HashMap; +use std::fmt; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + MoveDown, + MoveUp, + HalfPageDown, + HalfPageUp, + PageDown, + PageUp, + GoTop, + GoBottom, + Menu, + Delete, + NextView, + PrevView, + ViewResults, + ViewQueue, + ViewPlaylists, + Activate, + Pause, + Next, + Prev, + SeekBack, + SeekForward, + VolumeUp, + VolumeDown, + Add, + AddAll, + PlayAll, + MoveItemDown, + MoveItemUp, + Shuffle, + Repeat, + Search, + Command, + LyricsPanel, + Lyrics, + GoArtist, + GoSimilar, + GoAlbum, + SaveAlbum, + Help, + Quit, + Back, +} + +const DEFAULTS: &[(&str, Action, &[&str])] = &[ + ("move_down", Action::MoveDown, &["j", "down"]), + ("move_up", Action::MoveUp, &["k", "up"]), + ("half_page_down", Action::HalfPageDown, &["ctrl+d"]), + ("half_page_up", Action::HalfPageUp, &["ctrl+u"]), + ("page_down", Action::PageDown, &["ctrl+f", "pagedown"]), + ("page_up", Action::PageUp, &["ctrl+b", "pageup"]), + ("go_top", Action::GoTop, &["gg", "home"]), + ("go_bottom", Action::GoBottom, &["ge", "end"]), + ("menu", Action::Menu, &["G", "menu"]), + ("delete", Action::Delete, &["x", "delete", "dd"]), + ("next_view", Action::NextView, &["tab"]), + ("prev_view", Action::PrevView, &["backtab"]), + ("view_results", Action::ViewResults, &["gr"]), + ("view_queue", Action::ViewQueue, &["gq"]), + ("view_playlists", Action::ViewPlaylists, &["gp"]), + ("activate", Action::Activate, &["enter"]), + ("pause", Action::Pause, &["space"]), + ("next", Action::Next, &["n", ">"]), + ("prev", Action::Prev, &["N", "<"]), + ("seek_back", Action::SeekBack, &["h", "left"]), + ("seek_forward", Action::SeekForward, &["l", "right"]), + ("volume_up", Action::VolumeUp, &["+", "="]), + ("volume_down", Action::VolumeDown, &["-", "_"]), + ("add", Action::Add, &["a"]), + ("add_all", Action::AddAll, &["A"]), + ("play_all", Action::PlayAll, &["P"]), + ("move_item_down", Action::MoveItemDown, &["J"]), + ("move_item_up", Action::MoveItemUp, &["K"]), + ("shuffle", Action::Shuffle, &["s"]), + ("repeat", Action::Repeat, &["r"]), + ("search", Action::Search, &["/"]), + ("command", Action::Command, &[":"]), + ("lyrics_panel", Action::LyricsPanel, &["L"]), + ("lyrics", Action::Lyrics, &["F"]), + ("go_artist", Action::GoArtist, &["ga"]), + ("go_similar", Action::GoSimilar, &["gs"]), + ("go_album", Action::GoAlbum, &["gb"]), + ("save_album", Action::SaveAlbum, &["gB"]), + ("help", Action::Help, &["?"]), + ("quit", Action::Quit, &["q"]), + ("back", Action::Back, &["backspace", "esc"]), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Key { + code: KeyCode, + ctrl: bool, +} + +impl Key { + pub fn digit(&self) -> Option { + match self.code { + KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize), + _ => None, + } + } +} + +impl From for Key { + fn from(e: KeyEvent) -> Key { + Key { + code: e.code, + ctrl: e.modifiers.contains(KeyModifiers::CONTROL) + && !e.modifiers.contains(KeyModifiers::ALT), + } + } +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.ctrl { + f.write_str("^")?; + } + match self.code { + KeyCode::Char(c) => write!(f, "{c}"), + other => write!(f, "{other}"), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Keys { + One(String), + Many(Vec), +} + +impl Keys { + fn as_slice(&self) -> &[String] { + match self { + Keys::One(s) => std::slice::from_ref(s), + Keys::Many(v) => v, + } + } +} + +pub enum Lookup { + Run(Action), + Pending, + Nothing, +} + +#[derive(Debug, Clone)] +pub struct Keymap(Vec<(Vec, Action)>); + +impl Default for Keymap { + fn default() -> Keymap { + Keymap::build(&HashMap::new()).expect("default keys are valid") + } +} + +impl Keymap { + pub fn build(custom: &HashMap) -> Result { + if let Some(name) = custom + .keys() + .find(|n| !DEFAULTS.iter().any(|d| d.0 == n.as_str())) + { + return Err(format!("unknown action \"{name}\"")); + } + + let mut map: Vec<(Vec, Action)> = Vec::new(); + for (name, action, _) in DEFAULTS { + let Some(keys) = custom.get(*name) else { + continue; + }; + for text in keys.as_slice() { + let seq = parse_sequence(text).map_err(|e| format!("{name}: {e}"))?; + if let Some((_, other)) = map.iter().find(|(s, _)| *s == seq) { + if other != action { + return Err(format!("\"{text}\" is bound to two actions")); + } + continue; + } + map.push((seq, *action)); + } + } + + for (name, action, keys) in DEFAULTS { + if custom.contains_key(*name) { + continue; + } + for text in *keys { + let seq = parse_sequence(text).expect("default keys are valid"); + if !map.iter().any(|(s, _)| *s == seq) { + map.push((seq, *action)); + } + } + } + Ok(Keymap(map)) + } + + pub fn lookup(&self, seq: &[Key]) -> Lookup { + if let Some((_, action)) = self.0.iter().find(|(s, _)| s == seq) { + Lookup::Run(*action) + } else if self.0.iter().any(|(s, _)| s.starts_with(seq)) { + Lookup::Pending + } else { + Lookup::Nothing + } + } +} + +fn parse_sequence(text: &str) -> Result, String> { + let mut keys = Vec::new(); + for token in text.split_whitespace() { + match parse_key(token) { + Some(key) => keys.push(key), + None if !token.contains('+') => keys.extend(token.chars().map(|c| Key { + code: KeyCode::Char(c), + ctrl: false, + })), + None => return Err(format!("unknown key \"{token}\"")), + } + } + if keys.is_empty() { + return Err("empty key".into()); + } + Ok(keys) +} + +fn parse_key(token: &str) -> Option { + let (ctrl, name) = match token.get(..5) { + Some(p) if p.eq_ignore_ascii_case("ctrl+") => (true, &token[5..]), + _ => (false, token), + }; + let mut chars = name.chars(); + let code = match (chars.next()?, chars.next()) { + (c, None) if ctrl => KeyCode::Char(c.to_ascii_lowercase()), + (c, None) => KeyCode::Char(c), + _ => match name.to_ascii_lowercase().as_str() { + "space" => KeyCode::Char(' '), + "enter" | "return" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "tab" => KeyCode::Tab, + "backtab" => KeyCode::BackTab, + "backspace" => KeyCode::Backspace, + "delete" | "del" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "menu" => KeyCode::Menu, + f => match f.strip_prefix('f')?.parse::() { + Ok(n @ 1..=12) => KeyCode::F(n), + _ => return None, + }, + }, + }; + Some(Key { code, ctrl }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn custom(entries: &[(&str, &[&str])]) -> HashMap { + entries + .iter() + .map(|(name, keys)| { + let keys = keys.iter().map(|k| k.to_string()).collect(); + (name.to_string(), Keys::Many(keys)) + }) + .collect() + } + + fn seq(text: &str) -> Vec { + parse_sequence(text).unwrap() + } + + fn run(map: &Keymap, text: &str) -> Option { + match map.lookup(&seq(text)) { + Lookup::Run(a) => Some(a), + _ => None, + } + } + + #[test] + fn defaults_are_vim() { + let map = Keymap::default(); + assert_eq!(run(&map, "j"), Some(Action::MoveDown)); + assert_eq!(run(&map, "down"), Some(Action::MoveDown)); + assert_eq!(run(&map, "gg"), Some(Action::GoTop)); + assert_eq!(run(&map, "dd"), Some(Action::Delete)); + assert_eq!(run(&map, "ctrl+d"), Some(Action::HalfPageDown)); + assert_eq!(run(&map, "G"), Some(Action::Menu)); + assert_eq!(run(&map, "space"), Some(Action::Pause)); + assert!(matches!(map.lookup(&seq("g")), Lookup::Pending)); + assert!(matches!(map.lookup(&seq("gz")), Lookup::Nothing)); + } + + #[test] + fn parsing() { + assert_eq!(seq("ctrl+D"), seq("ctrl+d")); + assert_eq!(seq("gg"), seq("g g")); + assert_eq!(seq("+").len(), 1); + assert_eq!(seq("ctrl+x g").len(), 2); + assert_eq!(seq("f5")[0].code, KeyCode::F(5)); + assert!(parse_sequence("").is_err()); + assert!(parse_sequence("ctrl+nope").is_err()); + assert!(parse_key("f13").is_none()); + } + + #[test] + fn custom_replaces_default_keys() { + let map = Keymap::build(&custom(&[("quit", &["Q"]), ("move_down", &["e"])])).unwrap(); + assert_eq!(run(&map, "Q"), Some(Action::Quit)); + assert_eq!(run(&map, "q"), None); + assert_eq!(run(&map, "e"), Some(Action::MoveDown)); + assert_eq!(run(&map, "j"), None); + assert_eq!(run(&map, "k"), Some(Action::MoveUp)); + } + + #[test] + fn custom_wins_over_other_defaults() { + let map = Keymap::build(&custom(&[("quit", &["x"])])).unwrap(); + assert_eq!(run(&map, "x"), Some(Action::Quit)); + assert_eq!(run(&map, "dd"), Some(Action::Delete)); + } + + #[test] + fn single_string_and_disable() { + let toml = "quit = \"Q\"\nshuffle = []"; + let keys: HashMap = toml::from_str(toml).unwrap(); + let map = Keymap::build(&keys).unwrap(); + assert_eq!(run(&map, "Q"), Some(Action::Quit)); + assert_eq!(run(&map, "s"), None); + } + + #[test] + fn bad_config_is_rejected() { + assert!(Keymap::build(&custom(&[("fly", &["f"])])).is_err()); + assert!(Keymap::build(&custom(&[("quit", &["ctrl+nope"])])).is_err()); + assert!(Keymap::build(&custom(&[("quit", &["z"]), ("add", &["z"])])).is_err()); + assert!(Keymap::build(&custom(&[("quit", &["z", "z"])])).is_ok()); + } +} diff --git a/src/main.rs b/src/main.rs index c3634f6..b43714a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod command; mod config; mod discord; +mod keys; mod lyrics; mod model; mod mpris; diff --git a/src/ui.rs b/src/ui.rs index e49af1a..1f27bdf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -432,8 +432,8 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { if let Some(c) = app.count { pending.push_str(&c.to_string()); } - if let Some(p) = app.pending { - pending.push(p); + for key in &app.pending { + pending.push_str(&key.to_string()); } if !pending.is_empty() { block = block.title_bottom(