Fix scroll: PgUp/PgDn move cursor in place, add Scrollbar widget

SectionTree.handle_nav() now takes viewport height:
- PgUp/PgDn move both cursor and viewport by one page, keeping the
  cursor at the same screen position
- Home/End jump to first/last item
- scroll_to_selected() uses actual viewport height instead of
  hardcoded 30

Added render_scrollable() in widgets.rs: renders a Paragraph with a
vertical Scrollbar when content exceeds the viewport. Used by the
conscious and subconscious screens.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 19:07:00 -04:00
parent 818cdcc4e5
commit 19bb6d02e3
3 changed files with 74 additions and 39 deletions

View file

@ -1,9 +1,11 @@
// widgets.rs — Shared TUI helpers and reusable components
use ratatui::{
layout::{Margin, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
Frame,
crossterm::event::KeyCode,
};
use crate::agent::context::ContextSection;
@ -46,6 +48,33 @@ pub fn format_ts_age(ts: i64) -> String {
format_age((now - ts).max(0) as f64)
}
/// Render a paragraph with a vertical scrollbar.
pub fn render_scrollable(
frame: &mut Frame,
area: Rect,
lines: Vec<Line<'_>>,
block: Block<'_>,
scroll: u16,
) {
let content_len = lines.len();
let para = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
frame.render_widget(para, area);
let visible = area.height.saturating_sub(2) as usize;
if content_len > visible {
let mut sb_state = ScrollbarState::new(content_len)
.position(scroll as usize);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
area.inner(Margin { vertical: 1, horizontal: 0 }),
&mut sb_state,
);
}
}
// ---------------------------------------------------------------------------
// SectionTree — expand/collapse tree renderer for ContextSection
// ---------------------------------------------------------------------------
@ -77,17 +106,35 @@ impl SectionTree {
sections.iter().map(|s| count(s, &self.expanded, &mut idx)).sum()
}
pub fn handle_nav(&mut self, code: KeyCode, sections: &[ContextSection]) {
pub fn handle_nav(&mut self, code: KeyCode, sections: &[ContextSection], height: u16) {
let item_count = self.item_count(sections);
let page = height.saturating_sub(2) as usize; // account for border
match code {
KeyCode::Up => {
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
self.scroll_to_selected();
}
KeyCode::Down => {
let max = item_count.saturating_sub(1);
self.selected = Some(self.selected.map_or(0, |s| (s + 1).min(max)));
self.scroll_to_selected();
}
KeyCode::PageUp => {
let sel = self.selected.unwrap_or(0);
self.selected = Some(sel.saturating_sub(page));
self.scroll = self.scroll.saturating_sub(page as u16);
return; // skip scroll_to_selected — we moved both together
}
KeyCode::PageDown => {
let max = item_count.saturating_sub(1);
let sel = self.selected.map_or(0, |s| (s + page).min(max));
self.selected = Some(sel);
self.scroll += page as u16;
return;
}
KeyCode::Home => {
self.selected = Some(0);
}
KeyCode::End => {
self.selected = Some(item_count.saturating_sub(1));
}
KeyCode::Right | KeyCode::Enter => {
if let Some(sel) = self.selected {
@ -99,19 +146,19 @@ impl SectionTree {
self.expanded.remove(&sel);
}
}
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
KeyCode::PageDown => { self.scroll += 20; }
_ => {}
}
self.scroll_to_selected(height);
}
fn scroll_to_selected(&mut self) {
fn scroll_to_selected(&mut self, height: u16) {
if let Some(sel) = self.selected {
let sel_line = sel as u16;
if sel_line < self.scroll + 2 {
self.scroll = sel_line.saturating_sub(2);
} else if sel_line > self.scroll + 30 {
self.scroll = sel_line.saturating_sub(15);
let visible = height.saturating_sub(2); // border
if sel_line < self.scroll {
self.scroll = sel_line;
} else if sel_line >= self.scroll + visible {
self.scroll = sel_line.saturating_sub(visible.saturating_sub(1));
}
}
}