1use ratatui::Frame;
13use ratatui::layout::Rect;
14use ratatui::text::{Line, Text};
15use ratatui::widgets::{Block, Paragraph, Wrap};
16
17#[derive(Debug, Clone)]
22pub struct ScrollState {
23 pub offset: u32,
25 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 pub fn scroll_up(&mut self, amount: u32) {
45 self.offset = self.offset.saturating_add(amount);
46 self.auto_scroll = false;
47 }
48
49 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 pub fn scroll_to_bottom(&mut self) {
59 self.offset = 0;
60 self.auto_scroll = true;
61 }
62}
63
64fn 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
74pub 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 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 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 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}