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:
parent
6d22f70192
commit
f45f663dc0
3 changed files with 30 additions and 65 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue