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]]
name = "lines"
version = "0.1.0"
version = "0.1.16"
dependencies = [
"base64",
"chrono",

View file

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

View file

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

View file

@ -30,6 +30,8 @@ mod tests {
use crate::app::{CustomLine, 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 {
lines: lines
.into_iter()
@ -42,6 +44,7 @@ mod tests {
cursor: 0,
focus: crate::app::CustomFocus::Lines,
command: command.to_string(),
list_state,
}
}

View file

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

View file

@ -8,7 +8,7 @@ use ratatui::{
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 {
Screen::Main => draw_main(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()
.direction(Direction::Vertical)
.margin(1)
@ -43,7 +43,7 @@ fn draw_main(f: &mut Frame, app: &App) {
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()
.direction(Direction::Vertical)
.margin(1)
@ -54,7 +54,7 @@ fn draw_custom(f: &mut Frame, app: &App) {
])
.split(f.area());
let Some(state) = app.custom_state.as_ref() else {
let Some(state) = app.custom_state.as_mut() else {
return;
};
@ -94,7 +94,8 @@ fn draw_custom(f: &mut Frame, app: &App) {
Style::default().fg(Color::DarkGray)
});
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())
.block(
@ -119,7 +120,7 @@ fn draw_custom(f: &mut Frame, app: &App) {
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()
.direction(Direction::Vertical)
.margin(1)
@ -130,7 +131,7 @@ fn draw_search(f: &mut Frame, app: &App) {
])
.split(f.area());
let Some(state) = app.search_state.as_ref() else {
let Some(state) = app.search_state.as_mut() else {
return;
};
@ -170,7 +171,8 @@ fn draw_search(f: &mut Frame, app: &App) {
.title(format!(" Results ({}) ", state.results.len()))
.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
.results
@ -183,7 +185,7 @@ fn draw_search(f: &mut Frame, app: &App) {
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
.groups
.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));
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
.current_files()
.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));
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
.current_sections()
.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));
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) {