1use turbo_vision::core::draw::{Cell, DrawBuffer};
7use turbo_vision::core::event::{
8 Event, EventType, KB_DOWN, KB_END, KB_HOME, KB_PGDN, KB_PGUP, KB_UP,
9};
10use turbo_vision::core::geometry::Rect;
11use turbo_vision::core::palette::{Attr, TvColor};
12use turbo_vision::terminal::Terminal;
13use turbo_vision::views::view::{View, write_line_to_terminal};
14
15pub const DEFAULT_MAX_LINES: usize = 10_000;
17
18#[derive(Debug)]
21pub struct StreamView {
22 bounds: Rect,
23 lines: Vec<Vec<Cell>>,
25 partial: Option<Vec<Cell>>,
27 max_lines: usize,
28 top: usize,
30 left: usize,
32 follow: bool,
34 fill: Attr,
35}
36
37impl StreamView {
38 #[must_use]
39 pub fn new(bounds: Rect) -> Self {
40 Self {
41 bounds,
42 lines: Vec::new(),
43 partial: None,
44 max_lines: DEFAULT_MAX_LINES,
45 top: 0,
46 left: 0,
47 follow: true,
48 fill: Attr::new(TvColor::LightGray, TvColor::Black),
49 }
50 }
51
52 pub fn set_max_lines(&mut self, n: usize) {
53 self.max_lines = n.max(1);
54 self.trim();
55 }
56
57 pub fn push_line(&mut self, cells: Vec<Cell>) {
59 self.lines.push(cells);
60 self.trim();
61 if self.follow {
62 self.scroll_to_bottom();
63 }
64 }
65
66 pub fn set_partial(&mut self, cells: Vec<Cell>) {
69 self.partial = if cells.is_empty() { None } else { Some(cells) };
70 if self.follow {
71 self.scroll_to_bottom();
72 }
73 }
74
75 pub fn clear(&mut self) {
76 self.lines.clear();
77 self.partial = None;
78 self.top = 0;
79 self.left = 0;
80 self.follow = true;
81 }
82
83 #[must_use]
85 pub fn line_count(&self) -> usize {
86 self.lines.len() + usize::from(self.partial.is_some())
87 }
88
89 fn page(&self) -> usize {
91 usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
92 }
93
94 fn max_top(&self) -> usize {
95 self.line_count().saturating_sub(self.page())
96 }
97
98 pub fn scroll_to_bottom(&mut self) {
99 self.top = self.max_top();
100 self.follow = true;
101 }
102
103 pub fn scroll_to_top(&mut self) {
104 self.top = 0;
105 self.follow = false;
106 }
107
108 pub fn scroll_up(&mut self, n: usize) {
109 self.top = self.top.saturating_sub(n);
110 self.follow = false;
111 }
112
113 pub fn scroll_down(&mut self, n: usize) {
114 self.top = (self.top + n).min(self.max_top());
115 self.follow = self.top == self.max_top();
116 }
117
118 #[must_use]
119 pub fn is_at_bottom(&self) -> bool {
120 self.follow
121 }
122
123 #[must_use]
125 pub fn plain_text(&self) -> String {
126 let mut out = String::new();
127 for (i, line) in self.iter_lines().enumerate() {
128 if i > 0 {
129 out.push('\n');
130 }
131 out.extend(line.iter().map(|c| c.ch));
132 }
133 out
134 }
135
136 #[must_use]
138 pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
139 self.iter_lines().cloned().collect()
140 }
141
142 fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
143 self.lines.iter().chain(self.partial.iter())
144 }
145
146 fn trim(&mut self) {
147 if self.lines.len() > self.max_lines {
148 let drop = self.lines.len() - self.max_lines;
149 self.lines.drain(..drop);
150 self.top = self.top.saturating_sub(drop);
151 }
152 }
153}
154
155impl View for StreamView {
156 fn bounds(&self) -> Rect {
157 self.bounds
158 }
159
160 fn set_bounds(&mut self, bounds: Rect) {
161 self.bounds = bounds;
162 if self.follow {
163 self.scroll_to_bottom();
164 } else {
165 self.top = self.top.min(self.max_top());
166 }
167 }
168
169 fn draw(&mut self, terminal: &mut Terminal) {
170 if self.bounds.height() <= 0 {
171 return;
172 }
173 let width = usize::try_from(self.bounds.width()).unwrap_or(0);
174 let page = self.page();
175 let lines: Vec<&Vec<Cell>> = self.iter_lines().skip(self.top).take(page).collect();
176
177 for row in 0..page {
178 let mut buf = DrawBuffer::new(width);
179 for i in 0..width {
180 buf.put_char(i, ' ', self.fill);
181 }
182 if let Some(line) = lines.get(row) {
183 for (i, cell) in line.iter().skip(self.left).take(width).enumerate() {
184 buf.put_char(i, cell.ch, cell.attr);
185 }
186 }
187 let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
188 write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
189 }
190 }
191
192 fn handle_event(&mut self, event: &mut Event) {
193 if event.what != EventType::Keyboard {
194 return;
195 }
196 let page = self.page();
197 match event.key_code {
198 KB_UP => self.scroll_up(1),
199 KB_DOWN => self.scroll_down(1),
200 KB_PGUP => self.scroll_up(page),
201 KB_PGDN => self.scroll_down(page),
202 KB_HOME => self.scroll_to_top(),
203 KB_END => self.scroll_to_bottom(),
204 _ => return,
205 }
206 event.clear();
207 }
208
209 fn can_focus(&self) -> bool {
210 true
211 }
212
213 fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
214 None
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use std::io;
224 use std::time::Duration;
225 use turbo_vision::core::palette::TvColor;
226 use turbo_vision::terminal::Backend;
227
228 fn line(s: &str) -> Vec<Cell> {
229 s.chars()
230 .map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
231 .collect()
232 }
233
234 fn view() -> StreamView {
235 StreamView::new(Rect::new(0, 0, 40, 10))
236 }
237
238 struct FakeBackend {
243 width: u16,
244 height: u16,
245 }
246
247 impl Backend for FakeBackend {
248 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
249 self
250 }
251
252 fn init(&mut self) -> io::Result<()> {
253 Ok(())
254 }
255
256 fn cleanup(&mut self) -> io::Result<()> {
257 Ok(())
258 }
259
260 fn size(&self) -> io::Result<(u16, u16)> {
261 Ok((self.width, self.height))
262 }
263
264 fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
265 Ok(None)
266 }
267
268 fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
269 Ok(())
270 }
271
272 fn flush(&mut self) -> io::Result<()> {
273 Ok(())
274 }
275
276 fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
277 Ok(())
278 }
279
280 fn hide_cursor(&mut self) -> io::Result<()> {
281 Ok(())
282 }
283 }
284
285 fn fake_terminal(width: u16, height: u16) -> Terminal {
286 Terminal::with_backend(Box::new(FakeBackend { width, height }))
287 .expect("fake backend never fails to init")
288 }
289
290 #[test]
291 fn scrollback_cap_drops_oldest_lines() {
292 let mut v = view();
293 v.set_max_lines(3);
294 for i in 0..5 {
295 v.push_line(line(&i.to_string()));
296 }
297 assert_eq!(v.line_count(), 3);
298 assert_eq!(v.plain_text(), "2\n3\n4");
299 }
300
301 #[test]
302 fn autoscroll_holds_at_bottom_while_lines_arrive() {
303 let mut v = view();
304 for i in 0..50 {
305 v.push_line(line(&i.to_string()));
306 }
307 assert!(v.is_at_bottom());
308 }
309
310 #[test]
311 fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
312 let mut v = view();
313 for i in 0..50 {
314 v.push_line(line(&i.to_string()));
315 }
316 v.scroll_up(5);
317 assert!(!v.is_at_bottom());
318 v.push_line(line("new"));
319 assert!(
320 !v.is_at_bottom(),
321 "a new line must not yank a scrolled-back reader to the bottom"
322 );
323 v.scroll_to_bottom();
324 assert!(v.is_at_bottom());
325 }
326
327 #[test]
328 fn partial_line_is_replaced_not_appended() {
329 let mut v = view();
330 v.set_partial(line("par"));
331 v.set_partial(line("part"));
332 assert_eq!(v.plain_text(), "part");
333 assert_eq!(v.line_count(), 1);
334 }
335
336 #[test]
337 fn plain_text_strips_attributes() {
338 let mut v = view();
339 v.push_line(vec![Cell::new(
340 'x',
341 Attr::new(TvColor::LightRed, TvColor::Blue),
342 )]);
343 assert_eq!(v.plain_text(), "x");
344 }
345
346 #[test]
347 fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
348 let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
349 for i in 0..50 {
350 v.push_line(line(&i.to_string()));
351 }
352 v.scroll_to_top();
355 v.scroll_down(40);
356 assert!(!v.is_at_bottom());
357 let old_top = v.top;
358 assert!(old_top < v.max_top());
359
360 v.set_bounds(Rect::new(0, 0, 40, 48));
365
366 assert!(
367 v.top <= v.max_top(),
368 "top ({}) must not exceed max_top ({}) after growing",
369 v.top,
370 v.max_top()
371 );
372 let lines: Vec<&Vec<Cell>> = v.iter_lines().skip(v.top).take(v.page()).collect();
373 assert_eq!(
374 lines.len(),
375 v.page().min(v.line_count()),
376 "a full page of content should be visible after growing"
377 );
378 }
379
380 #[test]
381 fn draw_clips_to_bounds_and_applies_horizontal_offset() {
382 let mut v = StreamView::new(Rect::new(2, 1, 8, 4));
383 v.push_line(line("abcdefghij")); v.push_line(line("short")); v.left = 2; let mut terminal = fake_terminal(20, 10);
388 v.draw(&mut terminal);
389
390 for (i, expected) in "cdefgh".chars().enumerate() {
393 let cell = terminal
394 .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
395 .expect("cell within terminal bounds");
396 assert_eq!(cell.ch, expected);
397 }
398 assert_eq!(terminal.read_cell(8, 1).unwrap().ch, ' ');
400
401 for (i, expected) in "ort ".chars().enumerate() {
404 let cell = terminal
405 .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 2)
406 .expect("cell within terminal bounds");
407 assert_eq!(cell.ch, expected);
408 }
409
410 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
412 }
413
414 #[test]
415 fn draw_on_zero_height_view_writes_nothing() {
416 let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
417 v.push_line(line("hello"));
418 let mut terminal = fake_terminal(20, 10);
419 v.draw(&mut terminal);
420 for y in 0..10 {
421 for x in 0..20 {
422 assert_eq!(
423 terminal.read_cell(x, y).unwrap().ch,
424 ' ',
425 "zero-height view must not write any cell"
426 );
427 }
428 }
429 }
430}