tui: fix scroll by using Paragraph::line_count()

Replace homegrown wrapping math (wrapped_height, wrapped_height_line,
auto_scroll, force_scroll, wrapped_line_count) with ratatui's own
Paragraph::line_count() which exactly matches its rendering. The old
approach used ceiling division that didn't account for word wrapping,
causing bottom content to be clipped.

Also add terminal.clear() on resize to force full redraw — fixes the
TUI rendering at old canvas size after terminal resize.

Requires the unstable-rendered-line-info feature flag on ratatui.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-20 12:16:35 -04:00
parent 6d22f70192
commit f45f663dc0
3 changed files with 30 additions and 65 deletions

View file

@ -17,7 +17,7 @@ reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
ratatui = "0.30" ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
crossterm = { version = "0.29", features = ["event-stream"] } crossterm = { version = "0.29", features = ["event-stream"] }
walkdir = "2" walkdir = "2"
glob = "0.3" glob = "0.3"

View file

@ -1033,7 +1033,10 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
Some(Ok(Event::Mouse(mouse))) => { Some(Ok(Event::Mouse(mouse))) => {
app.handle_mouse(mouse); app.handle_mouse(mouse);
} }
Some(Ok(Event::Resize(_, _))) => {} Some(Ok(Event::Resize(w, h))) => {
app.handle_resize(w, h);
terminal.clear()?;
}
Some(Err(_)) => break, Some(Err(_)) => break,
None => break, None => break,
_ => continue, _ => continue,

View file

@ -107,10 +107,10 @@ struct PaneState {
scroll: u16, scroll: u16,
/// Whether the user has scrolled away from the bottom. /// Whether the user has scrolled away from the bottom.
pinned: bool, pinned: bool,
/// Last known inner dimensions (set during draw). Used by /// Last known total visual lines (set during draw by Paragraph::line_count).
/// scroll_down to compute max scroll without hardcoding. last_total_lines: u16,
/// Last known inner height (set during draw).
last_height: u16, last_height: u16,
last_width: u16,
} }
impl PaneState { impl PaneState {
@ -123,8 +123,8 @@ impl PaneState {
use_markdown, use_markdown,
scroll: 0, scroll: 0,
pinned: false, pinned: false,
last_total_lines: 0,
last_height: 20, last_height: 20,
last_width: 80,
} }
} }
@ -184,44 +184,15 @@ impl PaneState {
self.evict(); self.evict();
} }
/// Total visual (wrapped) lines given a pane width.
fn wrapped_line_count(&self, width: u16) -> u16 {
let w = width as usize;
let mut count: usize = 0;
for line in &self.lines {
count += wrapped_height_line(line, w);
}
if self.use_markdown && !self.md_buffer.is_empty() {
for line in parse_markdown(&self.md_buffer) {
count += wrapped_height_line(&line, w);
}
} else if !self.current_line.is_empty() {
count += wrapped_height(&self.current_line, w);
}
count.min(u16::MAX as usize) as u16
}
/// Auto-scroll to bottom unless user has pinned. Uses visual
/// (wrapped) line count so long lines don't cause clipping.
fn auto_scroll(&mut self, height: u16, width: u16) {
self.last_height = height;
self.last_width = width;
if !self.pinned {
let total = self.wrapped_line_count(width);
self.scroll = total.saturating_sub(height);
}
}
/// Scroll up by n visual lines, pinning if we move away from bottom. /// Scroll up by n visual lines, pinning if we move away from bottom.
fn scroll_up(&mut self, n: u16) { fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n); self.scroll = self.scroll.saturating_sub(n);
self.pinned = true; self.pinned = true;
} }
/// Scroll down by n visual lines, un-pinning if we reach bottom. /// Scroll down by n visual lines. Un-pin if we reach bottom.
fn scroll_down(&mut self, n: u16) { fn scroll_down(&mut self, n: u16) {
let total = self.wrapped_line_count(self.last_width); let max = self.last_total_lines.saturating_sub(self.last_height);
let max = total.saturating_sub(self.last_height);
self.scroll = (self.scroll + n).min(max); self.scroll = (self.scroll + n).min(max);
if self.scroll >= max { if self.scroll >= max {
self.pinned = false; self.pinned = false;
@ -245,28 +216,6 @@ impl PaneState {
} }
} }
/// How many visual lines a string occupies at a given width.
fn wrapped_height(line: &str, width: usize) -> usize {
if width == 0 || line.is_empty() {
return 1;
}
// Use unicode display width to match ratatui's Wrap behavior
let w = ratatui::text::Line::raw(line).width();
((w + width - 1) / width).max(1)
}
/// How many visual lines a ratatui Line occupies at a given width.
fn wrapped_height_line(line: &Line<'_>, width: usize) -> usize {
if width == 0 {
return 1;
}
let w = line.width();
if w == 0 {
return 1;
}
((w + width - 1) / width).max(1)
}
/// Compute soft line break positions for word-wrapped text. /// Compute soft line break positions for word-wrapped text.
/// Returns the character index where each soft line starts. /// Returns the character index where each soft line starts.
/// Matches ratatui Wrap { trim: false } — breaks at word boundaries. /// Matches ratatui Wrap { trim: false } — breaks at word boundaries.
@ -758,6 +707,12 @@ impl App {
} }
} }
/// Handle terminal resize. Scroll is recalculated in draw_pane
/// via Paragraph::line_count; terminal.clear() in main.rs forces
/// a full redraw.
pub fn handle_resize(&mut self, _width: u16, _height: u16) {
}
/// Handle mouse events: scroll wheel and click-to-select-pane. /// Handle mouse events: scroll wheel and click-to-select-pane.
pub fn handle_mouse(&mut self, mouse: MouseEvent) { pub fn handle_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind { match mouse.kind {
@ -1154,9 +1109,7 @@ fn draw_pane(
pane: &mut PaneState, pane: &mut PaneState,
is_active: bool, is_active: bool,
) { ) {
let inner_height = area.height.saturating_sub(2); // borders let inner_height = area.height.saturating_sub(2);
let inner_width = area.width.saturating_sub(2);
pane.auto_scroll(inner_height, inner_width);
let border_style = if is_active { let border_style = if is_active {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
@ -1171,10 +1124,19 @@ fn draw_pane(
let lines = pane.all_lines(); let lines = pane.all_lines();
let paragraph = Paragraph::new(lines) let paragraph = Paragraph::new(lines)
.block(block) .block(block.clone())
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false });
.scroll((pane.scroll, 0));
// Let ratatui tell us the total visual lines — no homegrown wrapping math.
let total = paragraph.line_count(area.width.saturating_sub(2)) as u16;
pane.last_total_lines = total;
pane.last_height = inner_height;
if !pane.pinned {
pane.scroll = total.saturating_sub(inner_height);
}
let paragraph = paragraph.scroll((pane.scroll, 0));
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
} }