fix(ui): auto-scroll lists when selection moves out of view

Use ratatui's ListState with render_stateful_widget in all list widgets
(groups, files, sections, custom lines, search results) so the viewport
follows the cursor instead of letting the selected item disappear behind
the border.

Bump version to 0.1.16.
This commit is contained in:
SW 2026-06-22 14:14:08 +02:00
parent 199fa5e652
commit 2e29267432
6 changed files with 47 additions and 17 deletions

2
Cargo.lock generated
View file

@ -825,7 +825,7 @@ dependencies = [
[[package]] [[package]]
name = "lines" name = "lines"
version = "0.1.0" version = "0.1.16"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "lines" name = "lines"
version = "0.1.15" version = "0.1.16"
edition = "2024" edition = "2024"
authors = ["janvete"] authors = ["janvete"]
description = "TUI for running frequently used commands" description = "TUI for running frequently used commands"

View file

@ -1,5 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use ratatui::widgets::ListState;
use crate::config::Terminal; use crate::config::Terminal;
use crate::parser::{load_groups, CommandFile, Group, Section}; use crate::parser::{load_groups, CommandFile, Group, Section};
@ -23,6 +25,7 @@ pub struct SearchState {
pub query: String, pub query: String,
pub results: Vec<SearchResult>, pub results: Vec<SearchResult>,
pub cursor: usize, pub cursor: usize,
pub list_state: ListState,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -52,6 +55,7 @@ pub struct CustomState {
pub cursor: usize, pub cursor: usize,
pub focus: CustomFocus, pub focus: CustomFocus,
pub command: String, pub command: String,
pub list_state: ListState,
} }
impl CustomState { impl CustomState {
@ -69,12 +73,15 @@ impl CustomState {
} }
} }
let selected = vec![false; lines.len()]; let selected = vec![false; lines.len()];
let mut list_state = ListState::default();
list_state.select(Some(0));
CustomState { CustomState {
lines, lines,
selected, selected,
cursor: 0, cursor: 0,
focus: CustomFocus::Lines, focus: CustomFocus::Lines,
command: String::new(), command: String::new(),
list_state,
} }
} }
@ -148,11 +155,20 @@ pub struct App {
pub search_state: Option<SearchState>, pub search_state: Option<SearchState>,
pub pending_command: Option<PendingCommand>, pub pending_command: Option<PendingCommand>,
pub message: Option<String>, pub message: Option<String>,
pub group_list_state: ListState,
pub file_list_state: ListState,
pub section_list_state: ListState,
} }
impl App { impl App {
pub fn new(data_dir: PathBuf, terminal: Terminal, shell: String) -> Self { pub fn new(data_dir: PathBuf, terminal: Terminal, shell: String) -> Self {
let groups = load_groups(&data_dir); let groups = load_groups(&data_dir);
let mut group_list_state = ListState::default();
let mut file_list_state = ListState::default();
let mut section_list_state = ListState::default();
group_list_state.select(Some(0));
file_list_state.select(Some(0));
section_list_state.select(Some(0));
App { App {
data_dir, data_dir,
terminal, terminal,
@ -167,6 +183,9 @@ impl App {
search_state: None, search_state: None,
pending_command: None, pending_command: None,
message: None, message: None,
group_list_state,
file_list_state,
section_list_state,
} }
} }
@ -191,10 +210,13 @@ impl App {
pub fn enter_search(&mut self) { pub fn enter_search(&mut self) {
let results = build_search_index(&self.groups); let results = build_search_index(&self.groups);
let mut list_state = ListState::default();
list_state.select(Some(0));
self.search_state = Some(SearchState { self.search_state = Some(SearchState {
query: String::new(), query: String::new(),
results, results,
cursor: 0, cursor: 0,
list_state,
}); });
self.screen = Screen::Search; self.screen = Screen::Search;
} }

View file

@ -30,6 +30,8 @@ mod tests {
use crate::app::{CustomLine, CustomState}; use crate::app::{CustomLine, CustomState};
fn make_state(lines: Vec<&str>, selected: Vec<bool>, command: &str) -> CustomState { fn make_state(lines: Vec<&str>, selected: Vec<bool>, command: &str) -> CustomState {
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(0));
CustomState { CustomState {
lines: lines lines: lines
.into_iter() .into_iter()
@ -42,6 +44,7 @@ mod tests {
cursor: 0, cursor: 0,
focus: crate::app::CustomFocus::Lines, focus: crate::app::CustomFocus::Lines,
command: command.to_string(), command: command.to_string(),
list_state,
} }
} }

View file

@ -90,7 +90,7 @@ where
let tick_rate = Duration::from_millis(100); let tick_rate = Duration::from_millis(100);
loop { loop {
terminal.draw(|f| ui::draw(f, &app))?; terminal.draw(|f| ui::draw(f, &mut app))?;
if let Some(event) = poll_event(tick_rate)? if let Some(event) = poll_event(tick_rate)?
&& !handle_event(&mut app, event) && !handle_event(&mut app, event)

View file

@ -8,7 +8,7 @@ use ratatui::{
use crate::app::{App, CustomFocus, Focus, Screen}; use crate::app::{App, CustomFocus, Focus, Screen};
pub fn draw(f: &mut Frame, app: &App) { pub fn draw(f: &mut Frame, app: &mut App) {
match app.screen { match app.screen {
Screen::Main => draw_main(f, app), Screen::Main => draw_main(f, app),
Screen::Custom => draw_custom(f, app), Screen::Custom => draw_custom(f, app),
@ -16,7 +16,7 @@ pub fn draw(f: &mut Frame, app: &App) {
} }
} }
fn draw_main(f: &mut Frame, app: &App) { fn draw_main(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@ -43,7 +43,7 @@ fn draw_main(f: &mut Frame, app: &App) {
draw_status(f, app, chunks[2]); draw_status(f, app, chunks[2]);
} }
fn draw_custom(f: &mut Frame, app: &App) { fn draw_custom(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@ -54,7 +54,7 @@ fn draw_custom(f: &mut Frame, app: &App) {
]) ])
.split(f.area()); .split(f.area());
let Some(state) = app.custom_state.as_ref() else { let Some(state) = app.custom_state.as_mut() else {
return; return;
}; };
@ -94,7 +94,8 @@ fn draw_custom(f: &mut Frame, app: &App) {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}); });
let list = List::new(items).block(list_block); let list = List::new(items).block(list_block);
f.render_widget(list, chunks[0]); state.list_state.select(Some(state.cursor));
f.render_stateful_widget(list, chunks[0], &mut state.list_state);
let input = Paragraph::new(state.command.as_str()) let input = Paragraph::new(state.command.as_str())
.block( .block(
@ -119,7 +120,7 @@ fn draw_custom(f: &mut Frame, app: &App) {
f.render_widget(paragraph, chunks[2]); f.render_widget(paragraph, chunks[2]);
} }
fn draw_search(f: &mut Frame, app: &App) { fn draw_search(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@ -130,7 +131,7 @@ fn draw_search(f: &mut Frame, app: &App) {
]) ])
.split(f.area()); .split(f.area());
let Some(state) = app.search_state.as_ref() else { let Some(state) = app.search_state.as_mut() else {
return; return;
}; };
@ -170,7 +171,8 @@ fn draw_search(f: &mut Frame, app: &App) {
.title(format!(" Results ({}) ", state.results.len())) .title(format!(" Results ({}) ", state.results.len()))
.borders(Borders::ALL), .borders(Borders::ALL),
); );
f.render_widget(list, chunks[1]); state.list_state.select(Some(state.cursor));
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
let preview_text = state let preview_text = state
.results .results
@ -183,7 +185,7 @@ fn draw_search(f: &mut Frame, app: &App) {
f.render_widget(preview, chunks[2]); f.render_widget(preview, chunks[2]);
} }
fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_groups(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.groups .groups
.iter() .iter()
@ -207,10 +209,11 @@ fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.border_style(border_style(app.focus == Focus::Groups)); .border_style(border_style(app.focus == Focus::Groups));
let list = List::new(items).block(block); let list = List::new(items).block(block);
f.render_widget(list, area); app.group_list_state.select(Some(app.group_index));
f.render_stateful_widget(list, area, &mut app.group_list_state);
} }
fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_files(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.current_files() .current_files()
.iter() .iter()
@ -234,10 +237,11 @@ fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.border_style(border_style(app.focus == Focus::Files)); .border_style(border_style(app.focus == Focus::Files));
let list = List::new(items).block(block); let list = List::new(items).block(block);
f.render_widget(list, area); app.file_list_state.select(Some(app.file_index));
f.render_stateful_widget(list, area, &mut app.file_list_state);
} }
fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_sections(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.current_sections() .current_sections()
.iter() .iter()
@ -266,7 +270,8 @@ fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.border_style(border_style(app.focus == Focus::Sections)); .border_style(border_style(app.focus == Focus::Sections));
let list = List::new(items).block(block); let list = List::new(items).block(block);
f.render_widget(list, area); app.section_list_state.select(Some(app.section_index));
f.render_stateful_widget(list, area, &mut app.section_list_state);
} }
fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {