Skip to main content

apiari_tui/
scroll.rs

1//! Visual-line-aware scrollable text rendering.
2//!
3//! Ratatui's `Paragraph::scroll()` counts **visual** lines (post-wrap), but most
4//! callers only know **logical** line counts.  When `Wrap` is enabled, long lines
5//! expand into multiple visual rows, so a naïve `total_logical - viewport` scroll
6//! offset falls short of the true bottom.
7//!
8//! `ScrollState` + `render_scrollable()` solve this by computing visual line
9//! counts the same way ratatui does (`ceil(line_width / viewport_width)`), keeping
10//! auto-scroll pinned to the real bottom.
11
12use ratatui::Frame;
13use ratatui::layout::Rect;
14use ratatui::text::{Line, Text};
15use ratatui::widgets::{Block, Paragraph, Wrap};
16
17/// Scroll state for a scrollable text region.
18///
19/// `offset == 0` means "pinned to the bottom" (auto-scroll).
20/// Scrolling up increases `offset`; scrolling back to 0 re-enables auto-scroll.
21#[derive(Debug, Clone)]
22pub struct ScrollState {
23    /// Lines scrolled up from the bottom (0 = follow latest content).
24    pub offset: u32,
25    /// Whether we're auto-following new content.
26    pub auto_scroll: bool,
27}
28
29impl Default for ScrollState {
30    fn default() -> Self {
31        Self {
32            offset: 0,
33            auto_scroll: true,
34        }
35    }
36}
37
38impl ScrollState {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Scroll up (away from bottom). Disables auto-scroll.
44    pub fn scroll_up(&mut self, amount: u32) {
45        self.offset = self.offset.saturating_add(amount);
46        self.auto_scroll = false;
47    }
48
49    /// Scroll down (toward bottom). Re-enables auto-scroll when reaching offset 0.
50    pub fn scroll_down(&mut self, amount: u32) {
51        self.offset = self.offset.saturating_sub(amount);
52        if self.offset == 0 {
53            self.auto_scroll = true;
54        }
55    }
56
57    /// Jump to bottom and re-enable auto-scroll.
58    pub fn scroll_to_bottom(&mut self) {
59        self.offset = 0;
60        self.auto_scroll = true;
61    }
62}
63
64/// Count total visual lines for `lines` at viewport width `w`.
65fn visual_line_count(lines: &[Line<'_>], w: usize) -> u32 {
66    let mut total: u32 = 0;
67    for line in lines {
68        let lw = line.width();
69        total += (lw.max(1).div_ceil(w)) as u32;
70    }
71    total
72}
73
74/// Render `lines` into `area` with visual-line-aware scrolling.
75///
76/// Handles both auto-scroll (pinned to bottom) and manual scroll modes.
77/// Uses `Wrap { trim: false }` so long lines wrap naturally.
78///
79/// The `block` is rendered first; scrollable content fills its inner area.
80/// Over-scroll is clamped — you can't scroll past the first line.
81pub fn render_scrollable<'a>(
82    frame: &mut Frame,
83    area: Rect,
84    lines: Vec<Line<'a>>,
85    scroll: &ScrollState,
86    block: Block<'a>,
87) {
88    let inner = block.inner(area);
89    frame.render_widget(block, area);
90
91    if inner.height == 0 || inner.width == 0 {
92        return;
93    }
94
95    let w = inner.width.max(1) as usize;
96    let visible_height = inner.height as u32;
97
98    if scroll.auto_scroll {
99        // Trim to a small tail so our visual-line estimate stays accurate
100        // (drift accumulates over many lines with ratatui's word-wrapping).
101        let keep_lines = (visible_height as usize) * 4 + 50;
102        let display_lines = if lines.len() > keep_lines {
103            &lines[lines.len() - keep_lines..]
104        } else {
105            &lines[..]
106        };
107
108        let tail_visual = visual_line_count(display_lines, w);
109        let scroll_rows = tail_visual.saturating_sub(visible_height);
110
111        let paragraph = Paragraph::new(Text::from(display_lines.to_vec()))
112            .scroll((scroll_rows.min(u16::MAX as u32) as u16, 0))
113            .wrap(Wrap { trim: false });
114        frame.render_widget(paragraph, inner);
115    } else {
116        // Manual scroll: use visual line count, clamp offset.
117        let total_visual = visual_line_count(&lines, w);
118        let max_offset = total_visual.saturating_sub(visible_height);
119        let clamped_offset = scroll.offset.min(max_offset);
120
121        let target_scroll = max_offset.saturating_sub(clamped_offset);
122
123        // For large offsets, drop earlier lines to avoid perf issues.
124        let (display_lines, effective_scroll) = if target_scroll > 500 {
125            let buffer = visible_height.max(100);
126            let drop_target = target_scroll.saturating_sub(buffer);
127            let mut drop_count = 0usize;
128            let mut dropped = 0u32;
129            for line in lines.iter() {
130                let lw = line.width();
131                let vl = (lw.max(1).div_ceil(w)) as u32;
132                if dropped + vl > drop_target {
133                    break;
134                }
135                dropped += vl;
136                drop_count += 1;
137            }
138            let adj = target_scroll - dropped;
139            (
140                Text::from(lines[drop_count..].to_vec()),
141                adj.min(u16::MAX as u32) as u16,
142            )
143        } else {
144            (Text::from(lines), target_scroll as u16)
145        };
146
147        let paragraph = Paragraph::new(display_lines)
148            .scroll((effective_scroll, 0))
149            .wrap(Wrap { trim: false });
150        frame.render_widget(paragraph, inner);
151    }
152}