Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Added

- **Configurable movement keybindings**: `h`/`j`/`k`/`l` keys (traditionally left/down/up/right) are now configurable via `move_left`, `move_down`, `move_up`, and `move_right` under `keybindings` in `config.yml` and via the Settings screen. Defaults match the previous hard-coded bindings, and the arrow key(s) fallbacks still work.

## [v0.39.1] 2026-06-12

### Fixed
Expand Down
52 changes: 52 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3531,6 +3531,30 @@ impl App {
description: "Go back / quit".to_string(),
value: SettingValue::Key(key_to_string(&self.user_config.keys.back)),
},
SettingItem {
id: "keys.move_up".to_string(),
name: "Move Up".to_string(),
description: "Move selection up".to_string(),
value: SettingValue::Key(key_to_string(&self.user_config.keys.move_up)),
},
SettingItem {
id: "keys.move_down".to_string(),
name: "Move Down".to_string(),
description: "Move selection down".to_string(),
value: SettingValue::Key(key_to_string(&self.user_config.keys.move_down)),
},
SettingItem {
id: "keys.move_left".to_string(),
name: "Move Left".to_string(),
description: "Move selection left".to_string(),
value: SettingValue::Key(key_to_string(&self.user_config.keys.move_left)),
},
SettingItem {
id: "keys.move_right".to_string(),
name: "Move Right".to_string(),
description: "Move selection right".to_string(),
value: SettingValue::Key(key_to_string(&self.user_config.keys.move_right)),
},
SettingItem {
id: "keys.next_page".to_string(),
name: "Next Page".to_string(),
Expand Down Expand Up @@ -3985,6 +4009,34 @@ impl App {
}
}
}
"keys.move_up" => {
if let SettingValue::Key(v) = &setting.value {
if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) {
self.user_config.keys.move_up = key;
}
}
}
"keys.move_down" => {
if let SettingValue::Key(v) = &setting.value {
if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) {
self.user_config.keys.move_down = key;
}
}
}
"keys.move_left" => {
if let SettingValue::Key(v) = &setting.value {
if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) {
self.user_config.keys.move_left = key;
}
}
}
"keys.move_right" => {
if let SettingValue::Key(v) = &setting.value {
if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) {
self.user_config.keys.move_right = key;
}
}
}
"keys.next_page" => {
if let SettingValue::Key(v) = &setting.value {
if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) {
Expand Down
34 changes: 27 additions & 7 deletions src/core/user_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,10 +599,6 @@ pub fn parse_key_public(key: String) -> Result<Key> {

fn check_reserved_keys(key: Key) -> Result<()> {
let reserved = [
Key::Char('h'),
Key::Char('j'),
Key::Char('k'),
Key::Char('l'),
Key::Char('H'),
Key::Char('M'),
Key::Char('L'),
Expand Down Expand Up @@ -638,6 +634,10 @@ pub struct UserConfigPaths {
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KeyBindingsString {
back: Option<String>,
move_up: Option<String>,
move_down: Option<String>,
move_left: Option<String>,
move_right: Option<String>,
next_page: Option<String>,
previous_page: Option<String>,
jump_to_start: Option<String>,
Expand Down Expand Up @@ -678,6 +678,10 @@ pub struct KeyBindingsString {
#[derive(Clone)]
pub struct KeyBindings {
pub back: Key,
pub move_up: Key,
pub move_down: Key,
pub move_left: Key,
pub move_right: Key,
pub next_page: Key,
pub previous_page: Key,
pub jump_to_start: Key,
Expand Down Expand Up @@ -845,6 +849,10 @@ impl UserConfig {
custom_theme: Default::default(),
keys: KeyBindings {
back: Key::Char('q'),
move_up: Key::Char('k'),
move_down: Key::Char('j'),
move_left: Key::Char('h'),
move_right: Key::Char('l'),
next_page: Key::Ctrl('d'),
previous_page: Key::Ctrl('u'),
jump_to_start: Key::Ctrl('a'),
Expand Down Expand Up @@ -971,6 +979,10 @@ impl UserConfig {
}

to_keys!(back);
to_keys!(move_up);
to_keys!(move_down);
to_keys!(move_left);
to_keys!(move_right);
to_keys!(next_page);
to_keys!(previous_page);
to_keys!(jump_to_start);
Expand Down Expand Up @@ -1283,6 +1295,10 @@ impl UserConfig {
let k = &self.keys;
vec![
k.back,
k.move_up,
k.move_down,
k.move_left,
k.move_right,
k.next_page,
k.previous_page,
k.jump_to_start,
Expand Down Expand Up @@ -1478,6 +1494,10 @@ impl UserConfig {
// Helper to build keybindings config from current values
let build_keybindings = || KeyBindingsString {
back: Some(key_to_config_string(self.keys.back)),
move_up: Some(key_to_config_string(self.keys.move_up)),
move_down: Some(key_to_config_string(self.keys.move_down)),
move_left: Some(key_to_config_string(self.keys.move_left)),
move_right: Some(key_to_config_string(self.keys.move_right)),
next_page: Some(key_to_config_string(self.keys.next_page)),
previous_page: Some(key_to_config_string(self.keys.previous_page)),
jump_to_start: Some(key_to_config_string(self.keys.jump_to_start)),
Expand Down Expand Up @@ -1901,10 +1921,10 @@ mod tests {

let mut config = UserConfig::new();
let mut entries = HashMap::new();
// 'j' is a reserved key
entries.insert("go_down".to_string(), "j".to_string());
// Enter is a reserved key
entries.insert("submit_action".to_string(), "enter".to_string());
config.load_plugin_commands(entries);
assert!(!config.plugin_command_keys.contains_key(&Key::Char('j')));
assert!(!config.plugin_command_keys.contains_key(&Key::Enter));
}

#[test]
Expand Down
8 changes: 5 additions & 3 deletions src/tui/handlers/album_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ use crate::{

pub fn handler(key: Key, app: &mut App) {
match key {
k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),
k if common_key_events::down_event(k) => {
k if common_key_events::left_event(k, &app.user_config.keys) => {
common_key_events::handle_left_event(app)
}
k if common_key_events::down_event(k, &app.user_config.keys) => {
if let Some(albums) = &mut app.library.saved_albums.get_results(None) {
let next_index =
common_key_events::on_down_press_handler(&albums.items, Some(app.album_list_index));
app.album_list_index = next_index;
}
}
k if common_key_events::up_event(k) => {
k if common_key_events::up_event(k, &app.user_config.keys) => {
if let Some(albums) = &mut app.library.saved_albums.get_results(None) {
let next_index =
common_key_events::on_up_press_handler(&albums.items, Some(app.album_list_index));
Expand Down
8 changes: 5 additions & 3 deletions src/tui/handlers/album_tracks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ use rspotify::{

pub fn handler(key: Key, app: &mut App) {
match key {
k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),
k if common_key_events::down_event(k) => match app.album_table_context {
k if common_key_events::left_event(k, &app.user_config.keys) => {
common_key_events::handle_left_event(app)
}
k if common_key_events::down_event(k, &app.user_config.keys) => match app.album_table_context {
AlbumTableContext::Full => {
if let Some(selected_album) = &app.selected_album_full {
let next_index = common_key_events::on_down_press_handler(
Expand All @@ -30,7 +32,7 @@ pub fn handler(key: Key, app: &mut App) {
}
}
},
k if common_key_events::up_event(k) => match app.album_table_context {
k if common_key_events::up_event(k, &app.user_config.keys) => match app.album_table_context {
AlbumTableContext::Full => {
if let Some(selected_album) = &app.selected_album_full {
let next_index = common_key_events::on_up_press_handler(
Expand Down
8 changes: 4 additions & 4 deletions src/tui/handlers/artist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,21 +248,21 @@ pub fn handler(key: Key, app: &mut App) {
Key::Esc => {
artist.artist_selected_block = ArtistBlock::Empty;
}
k if common_key_events::down_event(k) => {
k if common_key_events::down_event(k, &app.user_config.keys) => {
if artist.artist_selected_block != ArtistBlock::Empty {
handle_down_press_on_selected_block(app);
} else {
handle_down_press_on_hovered_block(app);
}
}
k if common_key_events::up_event(k) => {
k if common_key_events::up_event(k, &app.user_config.keys) => {
if artist.artist_selected_block != ArtistBlock::Empty {
handle_up_press_on_selected_block(app);
} else {
handle_up_press_on_hovered_block(app);
}
}
k if common_key_events::left_event(k) => {
k if common_key_events::left_event(k, &app.user_config.keys) => {
artist.artist_selected_block = ArtistBlock::Empty;
match artist.artist_hovered_block {
ArtistBlock::TopTracks => common_key_events::handle_left_event(app),
Expand All @@ -275,7 +275,7 @@ pub fn handler(key: Key, app: &mut App) {
ArtistBlock::Empty => {}
}
}
k if common_key_events::right_event(k) => {
k if common_key_events::right_event(k, &app.user_config.keys) => {
artist.artist_selected_block = ArtistBlock::Empty;
handle_down_press_on_hovered_block(app);
}
Expand Down
6 changes: 3 additions & 3 deletions src/tui/handlers/artist_albums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use crate::{

pub fn handler(key: Key, app: &mut App) {
match key {
k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),
k if common_key_events::down_event(k) => {
k if common_key_events::left_event(k, &app.user_config.keys) => common_key_events::handle_left_event(app),
k if common_key_events::down_event(k, &app.user_config.keys) => {
if let Some(artist_albums) = &mut app.artist_albums {
let next_index = common_key_events::on_down_press_handler(
&artist_albums.albums.items,
Expand All @@ -16,7 +16,7 @@ pub fn handler(key: Key, app: &mut App) {
artist_albums.selected_index = next_index;
}
}
k if common_key_events::up_event(k) => {
k if common_key_events::up_event(k, &app.user_config.keys) => {
if let Some(artist_albums) = &mut app.artist_albums {
let next_index = common_key_events::on_up_press_handler(
&artist_albums.albums.items,
Expand Down
8 changes: 5 additions & 3 deletions src/tui/handlers/artists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ use rspotify::prelude::*;

pub fn handler(key: Key, app: &mut App) {
match key {
k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),
k if common_key_events::down_event(k) => {
k if common_key_events::left_event(k, &app.user_config.keys) => {
common_key_events::handle_left_event(app)
}
k if common_key_events::down_event(k, &app.user_config.keys) => {
if let Some(artists) = &mut app.library.saved_artists.get_results(None) {
let next_index =
common_key_events::on_down_press_handler(&artists.items, Some(app.artists_list_index));
app.artists_list_index = next_index;
}
}
k if common_key_events::up_event(k) => {
k if common_key_events::up_event(k, &app.user_config.keys) => {
if let Some(artists) = &mut app.library.saved_artists.get_results(None) {
let next_index =
common_key_events::on_up_press_handler(&artists.items, Some(app.artists_list_index));
Expand Down
17 changes: 9 additions & 8 deletions src/tui/handlers/common_key_events.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
use crate::core::app::{ActiveBlock, App, RouteId};
use crate::core::user_config::KeyBindings;
use crate::tui::event::Key;

pub fn down_event(key: Key) -> bool {
matches!(key, Key::Down | Key::Char('j') | Key::Ctrl('n'))
pub fn down_event(key: Key, keys: &KeyBindings) -> bool {
matches!(key, Key::Down | Key::Ctrl('n')) || key == keys.move_down
}

pub fn up_event(key: Key) -> bool {
matches!(key, Key::Up | Key::Char('k') | Key::Ctrl('p'))
pub fn up_event(key: Key, keys: &KeyBindings) -> bool {
matches!(key, Key::Up | Key::Ctrl('p')) || key == keys.move_up
}

pub fn left_event(key: Key) -> bool {
matches!(key, Key::Left | Key::Char('h') | Key::Ctrl('b'))
pub fn left_event(key: Key, keys: &KeyBindings) -> bool {
matches!(key, Key::Left | Key::Ctrl('b')) || key == keys.move_left
}

pub fn right_event(key: Key) -> bool {
matches!(key, Key::Right | Key::Char('l') | Key::Ctrl('f'))
pub fn right_event(key: Key, keys: &KeyBindings) -> bool {
matches!(key, Key::Right | Key::Ctrl('f')) || key == keys.move_right
}

pub fn high_event(key: Key) -> bool {
Expand Down
8 changes: 4 additions & 4 deletions src/tui/handlers/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ fn handle_confirmation_dialog(key: Key, app: &mut App, dialog_context: DialogCon
}
close_dialog(app);
}
k if common_key_events::right_event(k) => app.confirm = !app.confirm,
k if common_key_events::left_event(k) => app.confirm = !app.confirm,
k if common_key_events::right_event(k, &app.user_config.keys) => app.confirm = !app.confirm,
k if common_key_events::left_event(k, &app.user_config.keys) => app.confirm = !app.confirm,
_ => {}
}
}
Expand All @@ -56,14 +56,14 @@ fn handle_add_to_playlist_picker(key: Key, app: &mut App) {
let editable_playlists = app.editable_playlists();
let playlist_count = editable_playlists.len();
match key {
k if common_key_events::down_event(k) && playlist_count > 0 => {
k if common_key_events::down_event(k, &app.user_config.keys) && playlist_count > 0 => {
let next = common_key_events::on_down_press_handler(
&editable_playlists,
Some(app.playlist_picker_selected_index),
);
app.playlist_picker_selected_index = next;
}
k if common_key_events::up_event(k) && playlist_count > 0 => {
k if common_key_events::up_event(k, &app.user_config.keys) && playlist_count > 0 => {
let next = common_key_events::on_up_press_handler(
&editable_playlists,
Some(app.playlist_picker_selected_index),
Expand Down
12 changes: 8 additions & 4 deletions src/tui/handlers/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ const DISCOVER_OPTIONS_COUNT: usize = 2;

pub fn handler(key: Key, app: &mut App) {
match key {
k if common_key_events::left_event(k) => common_key_events::handle_left_event(app),
k if common_key_events::down_event(k) => {
k if common_key_events::left_event(k, &app.user_config.keys) => {
common_key_events::handle_left_event(app)
}
k if common_key_events::down_event(k, &app.user_config.keys) => {
let next_index = if app.discover_selected_index >= DISCOVER_OPTIONS_COUNT - 1 {
0
} else {
app.discover_selected_index + 1
};
app.discover_selected_index = next_index;
}
k if common_key_events::up_event(k) => {
k if common_key_events::up_event(k, &app.user_config.keys) => {
let next_index = if app.discover_selected_index == 0 {
DISCOVER_OPTIONS_COUNT - 1
} else {
Expand All @@ -26,7 +28,9 @@ pub fn handler(key: Key, app: &mut App) {
app.discover_selected_index = next_index;
}
// Left/Right to cycle time range (for Top Tracks)
k if common_key_events::right_event(k) && app.discover_selected_index == 1 => {
k if common_key_events::right_event(k, &app.user_config.keys)
&& app.discover_selected_index == 1 =>
{
// Only cycle time range when Top Tracks is selected
app.discover_time_range = app.discover_time_range.next();
// Clear cache so it refetches with new time range
Expand Down
Loading
Loading