From 513832127e414be0ba4eea653845a5f5ccb80ab0 Mon Sep 17 00:00:00 2001 From: oud Date: Mon, 26 Oct 2026 14:19:29 +0700 Subject: [PATCH 1/4] fix(settings): preserve offset on settings panel scroll --- src/core/app/view.rs | 1 + src/tui/ui/settings.rs | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) 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/ui/settings.rs b/src/tui/ui/settings.rs index 2d6e88dc..dfe70bac 100644 --- a/src/tui/ui/settings.rs +++ b/src/tui/ui/settings.rs @@ -4,6 +4,7 @@ use crate::tui::event::Key; use crate::tui::fuzzy::fuzzy_match; use crate::tui::theme::{EmphasisExt, ThemeExt}; use crate::tui::ui::popups::highlighted_spans; +use std::cell::Cell; use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Modifier, Style}, @@ -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 { From eeb53deb3f9d7ce804013ad8ccb7eaf71ce685b3 Mon Sep 17 00:00:00 2001 From: oud Date: Mon, 26 Oct 2026 14:31:39 +0700 Subject: [PATCH 2/4] test(settings): added regression test for offset preservation --- src/tui/ui/settings.rs | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/tui/ui/settings.rs b/src/tui/ui/settings.rs index dfe70bac..64956534 100644 --- a/src/tui/ui/settings.rs +++ b/src/tui/ui/settings.rs @@ -4,7 +4,6 @@ use crate::tui::event::Key; use crate::tui::fuzzy::fuzzy_match; use crate::tui::theme::{EmphasisExt, ThemeExt}; use crate::tui::ui::popups::highlighted_spans; -use std::cell::Cell; use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Modifier, Style}, @@ -12,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; @@ -618,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(); From 7950990f07455e5ca67160107a0b1e4bbeccd207 Mon Sep 17 00:00:00 2001 From: oud Date: Wed, 28 Oct 2026 17:30:11 +0700 Subject: [PATCH 3/4] fix(settings): use rendered scroll offset for mouse clicks --- src/tui/handlers/mouse.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) 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); } From 48607d03180e8927758aeeca20851f3cb931c494 Mon Sep 17 00:00:00 2001 From: oud Date: Wed, 28 Oct 2026 17:34:32 +0700 Subject: [PATCH 4/4] docs: changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc79fdb7..50755a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### 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. + ## [v0.47.0] 2026-10-25 ### Added