diff --git a/CHANGELOG.md b/CHANGELOG.md index a0da5450..e7951152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- **Smoother scrolling in terminal Settings**: Fixed an issue where navigating the Settings list caused the screen to jump erratically. The view now stays stable, scrolling only when your cursor reaches the top or bottom of the box. - **The AI DJ prompt's caret follows the cursor**: after Left, Home or a Backspace in the middle of the text, the caret stayed painted at the end of the prompt. It now sits where the next character goes ([#945](https://github.com/LargeModGames/spotatui/issues/945)). ## [v0.47.0] 2026-10-25 diff --git a/src/core/app/view.rs b/src/core/app/view.rs index 4a511fd9..f26f61da 100644 --- a/src/core/app/view.rs +++ b/src/core/app/view.rs @@ -211,6 +211,7 @@ pub struct ViewState { pub artist_albums_scroll_offset: Cell, pub artist_related_artists_scroll_offset: Cell, pub playlist_sync_scroll_offset: Cell, + pub settings_scroll_offset: Cell, } #[derive(Clone, Debug, PartialEq)] diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs index 51b398fa..cf8ae34c 100644 --- a/src/tui/handlers/mouse.rs +++ b/src/tui/handlers/mouse.rs @@ -360,14 +360,17 @@ fn selected_setting_expects_key_capture(app: &App) -> bool { fn select_clicked_setting(mouse_row: u16, list_area: Rect, app: &mut App) { // Rows are drawn from the filtered view, so a click resolves to a position in - // it before it means anything as an index into `settings_items`. The last - // frame scrolled to keep the selection from before this click on screen, so - // the scroll offset comes from that selection's position in the same view. + // it before it means anything as an index into `settings_items`. Use the last + // frame's offset because the selection can sit anywhere in the viewport. let visible = filtered_setting_indices(app); let selected_position = selected_setting_position(app).unwrap_or(0); - let Some(clicked_row) = - list_item_index_from_click(list_area, mouse_row, selected_position, None, visible.len()) - else { + let Some(clicked_row) = list_item_index_from_click( + list_area, + mouse_row, + selected_position, + Some(app.view.settings_scroll_offset.get()), + visible.len(), + ) else { return; }; let clicked_index = visible[clicked_row]; @@ -1813,6 +1816,9 @@ mod tests { #[test] fn clicking_the_top_row_of_a_scrolled_settings_list_selects_the_row_that_was_drawn() { + use crate::tui::ui::settings::draw_settings; + use ratatui::{backend::TestBackend, Terminal}; + let mut app = App::default(); app.view.size = Viewport { width: 100, @@ -1823,12 +1829,17 @@ mod tests { let areas = settings_layout_areas(&app).expect("settings layout areas"); let inner_height = (areas.list.height - 2) as usize; assert!( - visible.len() > inner_height, + visible.len() > inner_height + 2, "the list must overflow its box to scroll" ); - // The last row is selected, so the drawn list is scrolled to the bottom. + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); app.view.settings_selected_index = *visible.last().unwrap(); + terminal.draw(|f| draw_settings(f, &app)).unwrap(); + crate::tui::handlers::handle_app(Key::Up, &mut app); + crate::tui::handlers::handle_app(Key::Up, &mut app); + terminal.draw(|f| draw_settings(f, &app)).unwrap(); + handler( mouse_event( MouseEventKind::Down(MouseButton::Left), @@ -1840,7 +1851,7 @@ mod tests { assert_eq!( app.view.settings_selected_index, - visible[visible.len() - inner_height] + visible[app.view.settings_scroll_offset.get()] ); assert!(!app.view.settings_edit_mode); } diff --git a/src/tui/ui/settings.rs b/src/tui/ui/settings.rs index 2d6e88dc..64956534 100644 --- a/src/tui/ui/settings.rs +++ b/src/tui/ui/settings.rs @@ -11,6 +11,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs}, Frame, }; +use std::cell::Cell; const UNSAVED_PROMPT_WIDTH: u16 = 58; const UNSAVED_PROMPT_HEIGHT: u16 = 9; @@ -121,7 +122,7 @@ pub fn draw_settings(f: &mut Frame<'_>, app: &App) { let [tabs_area, list_area, help_area] = settings_layout(f.area()); draw_category_tabs(f, app, tabs_area); - draw_settings_list(f, app, list_area); + draw_settings_list(f, app, list_area, &app.view.settings_scroll_offset); draw_settings_help(f, app, help_area); if app.view.settings_unsaved_prompt_visible { @@ -154,7 +155,7 @@ fn draw_category_tabs(f: &mut Frame<'_>, app: &App, area: Rect) { f.render_widget(tabs, area); } -fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { +fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect, scroll_offset: &Cell) { let visible = filtered_settings(app); let items: Vec = visible .iter() @@ -313,6 +314,9 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { items }; + let visible_rows = area.height.saturating_sub(2) as usize; + let max_offset = items.len().saturating_sub(visible_rows); + let list = List::new(items) .block( Block::default() @@ -335,6 +339,10 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { ); let mut state = ListState::default(); + let offset = scroll_offset.get().min(max_offset); + + *state.offset_mut() = offset; + // The list renders the filtered view, so the highlight is a position in it, // not the index into `settings_items` the rest of the screen works with. state.select( @@ -344,6 +352,7 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { ); f.render_stateful_widget(list, area, &mut state); + scroll_offset.set(state.offset()); } fn format_terminal_input_caps(app: &App) -> String { @@ -609,6 +618,45 @@ mod tests { assert!(!rendered(&app).contains("Download from Spotify")); } + #[test] + fn settings_scroll_offset_survives_frames_and_clamps_when_the_list_shrinks() { + let mut app = App::default(); + app.settings_items = (0..10) + .map(|index| SettingItem { + id: format!("behavior.test_{index}"), + name: format!("Setting {index}"), + description: String::new(), + value: SettingValue::Number(index), + }) + .collect(); + // Row 6 is already visible in the five-row viewport at offset 4, so + // selecting it must not make the next frame recalculate the offset. + app.view.settings_selected_index = 6; + app.view.settings_scroll_offset.set(4); + + let mut terminal = Terminal::new(TestBackend::new(WIDTH, 7)).unwrap(); + for frame in 1..=2 { + terminal + .draw(|f| { + draw_settings_list(f, &app, f.area(), &app.view.settings_scroll_offset); + }) + .unwrap(); + assert_eq!( + app.view.settings_scroll_offset.get(), + 4, + "offset changed on frame {frame}" + ); + } + + app.settings_items.truncate(7); + terminal + .draw(|f| { + draw_settings_list(f, &app, f.area(), &app.view.settings_scroll_offset); + }) + .unwrap(); + assert_eq!(app.view.settings_scroll_offset.get(), 2); + } + fn rendered(app: &App) -> String { let mut terminal = Terminal::new(TestBackend::new(WIDTH, HEIGHT)).unwrap(); terminal.draw(|f| draw_settings(f, app)).unwrap();