From ce7c5a0df92844470811898b1da56eaaf25f167e Mon Sep 17 00:00:00 2001 From: yozlog Date: Tue, 26 May 2026 06:34:22 +0900 Subject: [PATCH] feat(ui): add Vim-style relative line numbers for lists and popups - Added `enable_relative_line_number` configuration option to `app.toml`. - Implemented right-aligned relative line numbers capped at 2 digits for all list panels and popups. - Implemented a hybrid relative line number style for track and episode tables, where the selected item shows its original absolute index, and others show their relative offset. - Added `ui.count_prefix` support for popup lists to enable count prefix movements (e.g., 3j/2k) in popups. - Resolved multiple borrow checker conflicts in search page and popup event handlers. - Updated examples and configuration documentation. --- docs/config.md | 1 + examples/app.toml | 1 + spotify_player/src/config/mod.rs | 3 + spotify_player/src/event/popup.rs | 19 +++--- spotify_player/src/ui/page.rs | 110 ++++++++++++++++++++++-------- spotify_player/src/ui/popup.rs | 3 +- spotify_player/src/ui/utils.rs | 18 ++++- 7 files changed, 116 insertions(+), 39 deletions(-) diff --git a/docs/config.md b/docs/config.md index 475696fc..f560862e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -73,6 +73,7 @@ spotify_player -o device.volume=80 -o theme=dracula | `volume_scroll_step` | Volume change step when using mouse scroll. | `5` | | `enable_mouse_scroll_volume` | Enable volume control via mouse scroll. | `true` | | `custom_queue` | Enable app-managed queue for custom playback integration (requires `streaming` feature). | `true` | +| `enable_relative_line_number` | Enable Vim-style relative line numbers for lists and popups. | `false` | | `device` | Device configuration (see below). | See below | ### Notes diff --git a/examples/app.toml b/examples/app.toml index f13e302f..0e786581 100644 --- a/examples/app.toml +++ b/examples/app.toml @@ -26,6 +26,7 @@ cover_img_width = 5 cover_img_pixels = 16 seek_duration_secs = 5 custom_queue = true +enable_relative_line_number = false [device] name = "spotify-player" diff --git a/spotify_player/src/config/mod.rs b/spotify_player/src/config/mod.rs index 1987d900..01f93f90 100644 --- a/spotify_player/src/config/mod.rs +++ b/spotify_player/src/config/mod.rs @@ -139,6 +139,8 @@ pub struct AppConfig { /// Requires streaming. When disabled, playback uses Spotify-native queue /// management. pub custom_queue: bool, + + pub enable_relative_line_number: bool, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -392,6 +394,7 @@ impl Default for AppConfig { enable_mouse_scroll_volume: true, custom_queue: true, + enable_relative_line_number: false, } } } diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs index 3b2bee84..8b4f8f72 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -464,21 +464,24 @@ fn handle_command_for_list_popup( on_choose_func: impl FnOnce(&mut UIStateGuard, usize) -> anyhow::Result<()>, on_close_func: impl FnOnce(&mut UIStateGuard), ) -> anyhow::Result { + let offset = ui.count_prefix.unwrap_or(1); let popup = ui.popup.as_mut().with_context(|| "expect a popup")?; let current_id = popup.list_selected().unwrap_or_default(); + if n_items == 0 { + return Ok(false); + } + match command { Command::SelectPreviousOrScrollUp => { - if current_id > 0 { - popup.list_select(Some(current_id - 1)); - on_select_func(ui, current_id - 1); - } + let next_id = current_id.saturating_sub(offset); + popup.list_select(Some(next_id)); + on_select_func(ui, next_id); } Command::SelectNextOrScrollDown => { - if current_id + 1 < n_items { - popup.list_select(Some(current_id + 1)); - on_select_func(ui, current_id + 1); - } + let next_id = std::cmp::min(current_id + offset, n_items - 1); + popup.list_select(Some(next_id)); + on_select_func(ui, next_id); } Command::ChooseSelected => { if current_id < n_items { diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index 10fb472e..befc4fc8 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -48,16 +48,16 @@ pub fn render_search_page( // 1. Get data let data = state.data.read(); - let (focus_state, current_query, line_input) = match ui.current_page() { + let (focus_state, current_query) = match ui.current_page() { PageState::Search { state, current_query, - line_input, - } => (state.focus, current_query, line_input), + .. + } => (state.focus, current_query.clone()), _ => return, }; - let search_results = data.caches.search.get(current_query); + let search_results = data.caches.search.get(¤t_query); // 2. Construct the page's layout let rect = construct_and_render_block("Search", &ui.theme, Borders::ALL, frame, rect); @@ -140,8 +140,9 @@ pub fn render_search_page( .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Tracks; + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; - utils::construct_list_widget(&ui.theme, track_items, is_active) + utils::construct_list_widget(&ui.theme, track_items, is_active, selected_index) }; let (album_list, n_albums) = { @@ -150,8 +151,9 @@ pub fn render_search_page( .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Albums; + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; - utils::construct_list_widget(&ui.theme, album_items, is_active) + utils::construct_list_widget(&ui.theme, album_items, is_active, selected_index) }; let (artist_list, n_artists) = { @@ -160,8 +162,9 @@ pub fn render_search_page( .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Artists; + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; - utils::construct_list_widget(&ui.theme, artist_items, is_active) + utils::construct_list_widget(&ui.theme, artist_items, is_active, selected_index) }; let (playlist_list, n_playlists) = { @@ -170,8 +173,9 @@ pub fn render_search_page( .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Playlists; + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; - utils::construct_list_widget(&ui.theme, playlist_items, is_active) + utils::construct_list_widget(&ui.theme, playlist_items, is_active, selected_index) }; let (show_list, n_shows) = { @@ -179,8 +183,9 @@ pub fn render_search_page( .map(|s| search_items(&s.shows)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Shows; + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; - utils::construct_list_widget(&ui.theme, show_items, is_active) + utils::construct_list_widget(&ui.theme, show_items, is_active, selected_index) }; let (episode_list, n_episodes) = { @@ -189,25 +194,27 @@ pub fn render_search_page( .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Episodes; + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; - utils::construct_list_widget(&ui.theme, episode_items, is_active) + utils::construct_list_widget(&ui.theme, episode_items, is_active, selected_index) }; // 4. Render the page's widgets - // Render the query input box - frame.render_widget( - line_input.widget(is_active && focus_state == SearchFocusState::Input), - search_input_rect, - ); - - // Render the search result windows. // Need mutable access to the list/table states stored inside the page state for rendering. let PageState::Search { - state: page_state, .. + state: page_state, + line_input, + .. } = ui.current_page_mut() else { return; }; + + // Render the query input box + frame.render_widget( + line_input.widget(is_active && focus_state == SearchFocusState::Input), + search_input_rect, + ); utils::render_list_window( frame, track_list, @@ -453,30 +460,39 @@ pub fn render_library_page( }) .collect::>(); + let is_playlist_active = is_active + && focus_state != LibraryFocusState::SavedAlbums + && focus_state != LibraryFocusState::FollowedArtists; + let playlist_selected = if is_playlist_active { ui.current_page_mut().selected() } else { None }; let (playlist_list, n_playlists) = utils::construct_list_widget( &ui.theme, items, - is_active - && focus_state != LibraryFocusState::SavedAlbums - && focus_state != LibraryFocusState::FollowedArtists, + is_playlist_active, + playlist_selected, ); // Construct the saved album window + let is_album_active = is_active && focus_state == LibraryFocusState::SavedAlbums; + let album_selected = if is_album_active { ui.current_page_mut().selected() } else { None }; let (album_list, n_albums) = utils::construct_list_widget( &ui.theme, ui.search_filtered_items(&data.user_data.saved_albums) .into_iter() .map(|a| (a.to_bidi_string(), curr_context_uri == Some(a.id.uri()))) .collect(), - is_active && focus_state == LibraryFocusState::SavedAlbums, + is_album_active, + album_selected, ); // Construct the followed artist window + let is_artist_active = is_active && focus_state == LibraryFocusState::FollowedArtists; + let artist_selected = if is_artist_active { ui.current_page_mut().selected() } else { None }; let (artist_list, n_artists) = utils::construct_list_widget( &ui.theme, ui.search_filtered_items(&data.user_data.followed_artists) .into_iter() .map(|a| (a.to_bidi_string(), curr_context_uri == Some(a.id.uri()))) .collect(), - is_active && focus_state == LibraryFocusState::FollowedArtists, + is_artist_active, + artist_selected, ); // 4. Render the page's widgets @@ -520,6 +536,7 @@ pub fn render_browse_page( let data = state.data.read(); // 2+3. Construct the page's layout and widgets + let selected_index = if is_active { ui.current_page_mut().selected() } else { None }; let (list, len) = match ui.current_page() { PageState::Browse { state: ui_state } => match ui_state { BrowsePageUIState::CategoryList { .. } => { @@ -533,6 +550,7 @@ pub fn render_browse_page( .map(|c| (c.name.clone(), false)) .collect(), is_active, + selected_index, ) } BrowsePageUIState::CategoryPlaylistList { category, .. } => { @@ -551,6 +569,7 @@ pub fn render_browse_page( .map(|c| (c.name.clone(), false)) .collect(), is_active, + selected_index, ) } }, @@ -894,10 +913,14 @@ fn render_artist_context_page_windows( .map(|a| (a.name.clone(), false)) .collect::>(); + let is_artist_active = is_active && focus_state == ArtistFocusState::RelatedArtists; + let selected_index = if is_artist_active { ui.current_page_mut().selected() } else { None }; + utils::construct_list_widget( &ui.theme, artist_items, - is_active && focus_state == ArtistFocusState::RelatedArtists, + is_artist_active, + selected_index, ) }; @@ -967,12 +990,27 @@ fn render_track_table( // enable Added column if any track in the table has added_at field specified let added_at_enabled = tracks.iter().any(|t| t.added_at > 0); + let selected_index = if is_active && configs.app_config.enable_relative_line_number { + ui.current_page_mut().selected() + } else { + None + }; + let n_tracks = tracks.len(); let rows = tracks .into_iter() .enumerate() .map(|(id, t)| { - let track_no = (id + 1).to_string(); + let track_no = match selected_index { + Some(sel_idx) => { + if id == sel_idx { + (id + 1).to_string() + } else { + (id as isize - sel_idx as isize).abs().to_string() + } + } + None => (id + 1).to_string(), + }; let (play_pause, style) = if playing_track_uri == t.id.uri() { (playing_id.to_string(), ui.theme.current_playing()) } else { @@ -1101,18 +1139,34 @@ fn render_episode_table( } } + let selected_index = if is_active && configs.app_config.enable_relative_line_number { + ui.current_page_mut().selected() + } else { + None + }; + let n_episodes = episodes.len(); let rows = episodes .into_iter() .enumerate() .map(|(id, e)| { - let (id, style) = if playing_episode_uri == e.id.uri() { + let index_str = match selected_index { + Some(sel_idx) => { + if id == sel_idx { + (id + 1).to_string() + } else { + (id as isize - sel_idx as isize).abs().to_string() + } + } + None => (id + 1).to_string(), + }; + let (id_str, style) = if playing_episode_uri == e.id.uri() { (playing_id.to_string(), ui.theme.current_playing()) } else { - ((id + 1).to_string(), Style::default()) + (index_str, Style::default()) }; Row::new(vec![ - Cell::from(id), + Cell::from(id_str), Cell::from(to_bidi_string(&e.name)), Cell::from(e.release_date.clone()), Cell::from(format!( diff --git a/spotify_player/src/ui/popup.rs b/spotify_player/src/ui/popup.rs index 46241079..cd670f9e 100644 --- a/spotify_player/src/ui/popup.rs +++ b/spotify_player/src/ui/popup.rs @@ -228,7 +228,8 @@ fn render_list_popup( let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(length)]).split(rect); let rect = construct_and_render_block(title, &ui.theme, Borders::ALL, frame, chunks[1]); - let (list, len) = utils::construct_list_widget(&ui.theme, items, true); + let selected_index = ui.popup.as_ref().and_then(|p| p.list_selected()); + let (list, len) = utils::construct_list_widget(&ui.theme, items, true, selected_index); utils::render_list_window( frame, diff --git a/spotify_player/src/ui/utils.rs b/spotify_player/src/ui/utils.rs index c6a8c8e2..6cb522bd 100644 --- a/spotify_player/src/ui/utils.rs +++ b/spotify_player/src/ui/utils.rs @@ -55,15 +55,29 @@ pub fn construct_list_widget<'a>( theme: &config::Theme, items: Vec<(String, bool)>, is_active: bool, + selected_index: Option, ) -> (List<'a>, usize) { + let configs = config::get_config(); let n_items = items.len(); ( List::new( items .into_iter() - .map(|(s, is_active)| { - ListItem::new(s).style(if is_active { + .enumerate() + .map(|(i, (s, is_playing))| { + let text = if is_active && configs.app_config.enable_relative_line_number { + if let Some(selected_index) = selected_index { + let diff = (i as isize - selected_index as isize).abs(); + let width = std::cmp::min(n_items.to_string().len(), 2); + format!("{:>width$} {}", diff, s, width = width) + } else { + s + } + } else { + s + }; + ListItem::new(text).style(if is_playing { theme.current_playing() } else { Style::default()