mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
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.
This commit is contained in:
parent
8da1876835
commit
4570de2da7
6 changed files with 546 additions and 93 deletions
48
README.md
48
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
|
||||
|
|
|
|||
196
src/app.rs
196
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<String>,
|
||||
hist_pos: Option<usize>,
|
||||
pub count: Option<usize>,
|
||||
pub pending: Option<char>,
|
||||
pub pending: Vec<Key>,
|
||||
|
||||
pub play_state: PlayState,
|
||||
pub position: f64,
|
||||
|
|
@ -232,6 +233,7 @@ pub struct App {
|
|||
mpris: Option<Mpris>,
|
||||
discord: Option<Discord>,
|
||||
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<usize>) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<String, Keys>,
|
||||
#[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::<Config>(&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();
|
||||
|
|
|
|||
353
src/keys.rs
Normal file
353
src/keys.rs
Normal file
|
|
@ -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<usize> {
|
||||
match self.code {
|
||||
KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyEvent> 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<String>),
|
||||
}
|
||||
|
||||
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<Key>, Action)>);
|
||||
|
||||
impl Default for Keymap {
|
||||
fn default() -> Keymap {
|
||||
Keymap::build(&HashMap::new()).expect("default keys are valid")
|
||||
}
|
||||
}
|
||||
|
||||
impl Keymap {
|
||||
pub fn build(custom: &HashMap<String, Keys>) -> Result<Keymap, String> {
|
||||
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<Key>, 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<Vec<Key>, 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<Key> {
|
||||
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::<u8>() {
|
||||
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<String, Keys> {
|
||||
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<Key> {
|
||||
parse_sequence(text).unwrap()
|
||||
}
|
||||
|
||||
fn run(map: &Keymap, text: &str) -> Option<Action> {
|
||||
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<String, Keys> = 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ mod app;
|
|||
mod command;
|
||||
mod config;
|
||||
mod discord;
|
||||
mod keys;
|
||||
mod lyrics;
|
||||
mod model;
|
||||
mod mpris;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue