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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/core/app/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ pub struct ViewState {
pub artist_albums_scroll_offset: Cell<usize>,
pub artist_related_artists_scroll_offset: Cell<usize>,
pub playlist_sync_scroll_offset: Cell<usize>,
pub settings_scroll_offset: Cell<usize>,
}

#[derive(Clone, Debug, PartialEq)]
Expand Down
29 changes: 20 additions & 9 deletions src/tui/handlers/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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);
}
Expand Down
52 changes: 50 additions & 2 deletions src/tui/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<usize>) {
let visible = filtered_settings(app);
let items: Vec<ListItem> = visible
.iter()
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
Loading