From f45f663dc072e543fa65edc758b8f1eff83f2d42 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Fri, 20 Mar 2026 12:16:35 -0400 Subject: [PATCH] tui: fix scroll by using Paragraph::line_count() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- poc-agent/Cargo.toml | 2 +- poc-agent/src/main.rs | 5 ++- poc-agent/src/tui.rs | 88 ++++++++++++------------------------------- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/poc-agent/Cargo.toml b/poc-agent/Cargo.toml index 8b4a97b..9b44b0c 100644 --- a/poc-agent/Cargo.toml +++ b/poc-agent/Cargo.toml @@ -17,7 +17,7 @@ reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } -ratatui = "0.30" +ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] } crossterm = { version = "0.29", features = ["event-stream"] } walkdir = "2" glob = "0.3" diff --git a/poc-agent/src/main.rs b/poc-agent/src/main.rs index 16cfd95..2cfb487 100644 --- a/poc-agent/src/main.rs +++ b/poc-agent/src/main.rs @@ -1033,7 +1033,10 @@ async fn run(cli: cli::CliArgs) -> Result<()> { Some(Ok(Event::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, None => break, _ => continue, diff --git a/poc-agent/src/tui.rs b/poc-agent/src/tui.rs index a6b3d6f..a31fd46 100644 --- a/poc-agent/src/tui.rs +++ b/poc-agent/src/tui.rs @@ -107,10 +107,10 @@ struct PaneState { scroll: u16, /// Whether the user has scrolled away from the bottom. pinned: bool, - /// Last known inner dimensions (set during draw). Used by - /// scroll_down to compute max scroll without hardcoding. + /// Last known total visual lines (set during draw by Paragraph::line_count). + last_total_lines: u16, + /// Last known inner height (set during draw). last_height: u16, - last_width: u16, } impl PaneState { @@ -123,8 +123,8 @@ impl PaneState { use_markdown, scroll: 0, pinned: false, + last_total_lines: 0, last_height: 20, - last_width: 80, } } @@ -184,44 +184,15 @@ impl PaneState { 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. fn scroll_up(&mut self, n: u16) { self.scroll = self.scroll.saturating_sub(n); 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) { - let total = self.wrapped_line_count(self.last_width); - let max = total.saturating_sub(self.last_height); + let max = self.last_total_lines.saturating_sub(self.last_height); self.scroll = (self.scroll + n).min(max); if self.scroll >= max { 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. /// Returns the character index where each soft line starts. /// 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. pub fn handle_mouse(&mut self, mouse: MouseEvent) { match mouse.kind { @@ -1154,9 +1109,7 @@ fn draw_pane( pane: &mut PaneState, is_active: bool, ) { - let inner_height = area.height.saturating_sub(2); // borders - let inner_width = area.width.saturating_sub(2); - pane.auto_scroll(inner_height, inner_width); + let inner_height = area.height.saturating_sub(2); let border_style = if is_active { Style::default().fg(Color::Cyan) @@ -1171,10 +1124,19 @@ fn draw_pane( let lines = pane.all_lines(); let paragraph = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((pane.scroll, 0)); + .block(block.clone()) + .wrap(Wrap { trim: false }); + // 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); }