1use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
7
8use turbo_vision::core::draw::{Cell, DrawBuffer};
9use turbo_vision::core::event::{
10 Event, EventType, KB_DOWN, KB_END, KB_HOME, KB_PGDN, KB_PGUP, KB_UP,
11};
12use turbo_vision::core::geometry::Rect;
13use turbo_vision::core::palette::{Attr, TvColor};
14use turbo_vision::terminal::Terminal;
15use turbo_vision::views::view::{View, write_line_to_terminal};
16
17const PRESENTATION_SELECTORS: [char; 2] = ['\u{FE0F}', '\u{FE0E}'];
29
30fn normalize_line(cells: &[Cell]) -> Vec<Cell> {
57 let mut out = Vec::with_capacity(cells.len());
58 let mut i = 0;
59 while i < cells.len() {
60 let cell = cells[i];
61 if cell.ch == '\0' {
62 out.push(cell);
63 i += 1;
64 continue;
65 }
66
67 let next = cells.get(i + 1).copied();
68 let selector = next.filter(|n| PRESENTATION_SELECTORS.contains(&n.ch));
69
70 let width = if let Some(sel) = selector {
71 let mut seq = String::with_capacity(cell.ch.len_utf8() + sel.ch.len_utf8());
72 seq.push(cell.ch);
73 seq.push(sel.ch);
74 seq.width()
75 } else {
76 cell.ch.width().unwrap_or(0)
77 };
78
79 if width == 0 {
80 i += if selector.is_some() { 2 } else { 1 };
81 continue;
82 }
83
84 out.push(cell);
85 if let Some(sel) = selector {
86 out.push(sel);
87 for _ in 2..width {
88 out.push(Cell::new('\0', cell.attr));
89 }
90 i += 2;
91 } else {
92 for _ in 1..width {
93 out.push(Cell::new('\0', cell.attr));
94 }
95 i += 1;
96 }
97 }
98 out
99}
100
101pub const DEFAULT_MAX_LINES: usize = 10_000;
103
104fn wrap_cells(cells: &[Cell], width: usize) -> Vec<Vec<Cell>> {
119 if width == 0 || cells.is_empty() {
120 return vec![cells.to_vec()];
121 }
122
123 let mut rows = Vec::new();
124 let mut rest = cells;
125 while rest.len() > width {
126 let mut break_at = None;
131 for i in (0..width).rev() {
132 if rest[i].ch.is_whitespace() {
133 break_at = Some(i);
134 break;
135 }
136 }
137 if let Some(i) = break_at {
138 rows.push(rest[..i].to_vec());
139 rest = &rest[i + 1..]; } else {
141 let mut cut = width;
146 if cut > 1 && rest.get(cut).is_some_and(|c| c.ch == '\0') {
147 cut -= 1;
148 }
149 rows.push(rest[..cut].to_vec());
150 rest = &rest[cut..];
151 }
152 }
153 rows.push(rest.to_vec());
154 rows
155}
156
157#[derive(Debug)]
160pub struct StreamView {
161 bounds: Rect,
162 lines: Vec<Vec<Cell>>,
167 partial: Option<Vec<Cell>>,
169 wrapped: Vec<Vec<Cell>>,
173 row_counts: Vec<usize>,
177 partial_wrapped: Vec<Vec<Cell>>,
181 max_lines: usize,
187 top: usize,
189 follow: bool,
191 fill: Attr,
192}
193
194impl StreamView {
195 #[must_use]
196 pub fn new(bounds: Rect) -> Self {
197 Self {
198 bounds,
199 lines: Vec::new(),
200 partial: None,
201 wrapped: Vec::new(),
202 row_counts: Vec::new(),
203 partial_wrapped: Vec::new(),
204 max_lines: DEFAULT_MAX_LINES,
205 top: 0,
206 follow: true,
207 fill: Attr::new(TvColor::LightGray, TvColor::Black),
208 }
209 }
210
211 fn width(&self) -> usize {
212 usize::try_from(self.bounds.width()).unwrap_or(0)
213 }
214
215 pub fn set_max_lines(&mut self, n: usize) {
216 self.max_lines = n.max(1);
217 self.trim();
218 }
219
220 pub fn push_line(&mut self, cells: &[Cell]) {
222 let normalized = normalize_line(cells);
223 let rows = wrap_cells(&normalized, self.width());
224 self.row_counts.push(rows.len());
225 self.wrapped.extend(rows);
226 self.lines.push(normalized);
227 self.trim();
228 if self.follow {
229 self.scroll_to_bottom();
230 }
231 }
232
233 pub fn set_partial(&mut self, cells: &[Cell]) {
236 let cells = normalize_line(cells);
237 if cells.is_empty() {
238 self.partial = None;
239 self.partial_wrapped.clear();
240 } else {
241 self.partial_wrapped = wrap_cells(&cells, self.width());
242 self.partial = Some(cells);
243 }
244 if self.follow {
245 self.scroll_to_bottom();
246 }
247 }
248
249 pub fn clear(&mut self) {
250 self.lines.clear();
251 self.partial = None;
252 self.wrapped.clear();
253 self.row_counts.clear();
254 self.partial_wrapped.clear();
255 self.top = 0;
256 self.follow = true;
257 }
258
259 #[must_use]
261 pub fn line_count(&self) -> usize {
262 self.lines.len() + usize::from(self.partial.is_some())
263 }
264
265 #[must_use]
270 pub fn row_count(&self) -> usize {
271 self.wrapped.len() + self.partial_wrapped.len()
272 }
273
274 fn page(&self) -> usize {
276 usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
277 }
278
279 fn max_top(&self) -> usize {
280 self.row_count().saturating_sub(self.page())
281 }
282
283 fn rewrap(&mut self) {
288 let width = self.width();
289 self.wrapped.clear();
290 self.row_counts.clear();
291 for line in &self.lines {
292 let rows = wrap_cells(line, width);
293 self.row_counts.push(rows.len());
294 self.wrapped.extend(rows);
295 }
296 self.partial_wrapped = match &self.partial {
297 Some(cells) => wrap_cells(cells, width),
298 None => Vec::new(),
299 };
300 }
301
302 pub fn scroll_to_bottom(&mut self) {
303 self.top = self.max_top();
304 self.follow = true;
305 }
306
307 pub fn scroll_to_top(&mut self) {
308 self.top = 0;
309 self.follow = false;
310 }
311
312 pub fn scroll_up(&mut self, n: usize) {
313 self.top = self.top.saturating_sub(n);
314 self.follow = false;
315 }
316
317 pub fn scroll_down(&mut self, n: usize) {
318 self.top = (self.top + n).min(self.max_top());
319 self.follow = self.top == self.max_top();
320 }
321
322 #[must_use]
323 pub fn is_at_bottom(&self) -> bool {
324 self.follow
325 }
326
327 #[must_use]
329 pub fn plain_text(&self) -> String {
330 let mut out = String::new();
331 for (i, line) in self.iter_lines().enumerate() {
332 if i > 0 {
333 out.push('\n');
334 }
335 out.extend(line.iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
339 }
340 out
341 }
342
343 #[must_use]
345 pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
346 self.iter_lines().cloned().collect()
347 }
348
349 fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
350 self.lines.iter().chain(self.partial.iter())
351 }
352
353 fn iter_rows(&self) -> impl Iterator<Item = &Vec<Cell>> {
356 self.wrapped.iter().chain(self.partial_wrapped.iter())
357 }
358
359 fn trim(&mut self) {
363 if self.lines.len() > self.max_lines {
364 let drop = self.lines.len() - self.max_lines;
365 self.lines.drain(..drop);
366 let dropped_rows: usize = self.row_counts.drain(..drop).sum();
367 self.wrapped.drain(..dropped_rows);
368 self.top = self.top.saturating_sub(dropped_rows);
369 }
370 }
371}
372
373impl View for StreamView {
374 fn bounds(&self) -> Rect {
375 self.bounds
376 }
377
378 fn set_bounds(&mut self, bounds: Rect) {
379 let width_changed = self.width() != usize::try_from(bounds.width()).unwrap_or(0);
380 self.bounds = bounds;
381 if width_changed {
382 self.rewrap();
383 }
384 if self.follow {
385 self.scroll_to_bottom();
386 } else {
387 self.top = self.top.min(self.max_top());
388 }
389 }
390
391 fn draw(&mut self, terminal: &mut Terminal) {
392 if self.bounds.height() <= 0 {
393 return;
394 }
395 let width = usize::try_from(self.bounds.width()).unwrap_or(0);
396 let page = self.page();
397 let rows: Vec<&Vec<Cell>> = self.iter_rows().skip(self.top).take(page).collect();
398
399 for row in 0..page {
400 let mut buf = DrawBuffer::new(width);
401 for i in 0..width {
402 buf.put_char(i, ' ', self.fill);
403 }
404 if let Some(line) = rows.get(row) {
405 for (i, cell) in line.iter().take(width).enumerate() {
406 buf.put_char(i, cell.ch, cell.attr);
407 }
408 }
409 let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
410 write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
411 }
412 }
413
414 fn handle_event(&mut self, event: &mut Event) {
415 if event.what != EventType::Keyboard {
416 return;
417 }
418 let page = self.page();
419 match event.key_code {
420 KB_UP => self.scroll_up(1),
421 KB_DOWN => self.scroll_down(1),
422 KB_PGUP => self.scroll_up(page),
423 KB_PGDN => self.scroll_down(page),
424 KB_HOME => self.scroll_to_top(),
425 KB_END => self.scroll_to_bottom(),
426 _ => return,
427 }
428 event.clear();
429 }
430
431 fn can_focus(&self) -> bool {
432 true
433 }
434
435 fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
436 None
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use std::io;
446 use std::time::Duration;
447 use turbo_vision::core::palette::TvColor;
448 use turbo_vision::terminal::Backend;
449
450 fn line(s: &str) -> Vec<Cell> {
451 s.chars()
452 .map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
453 .collect()
454 }
455
456 fn view() -> StreamView {
457 StreamView::new(Rect::new(0, 0, 40, 10))
458 }
459
460 struct FakeBackend {
465 width: u16,
466 height: u16,
467 }
468
469 impl Backend for FakeBackend {
470 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
471 self
472 }
473
474 fn init(&mut self) -> io::Result<()> {
475 Ok(())
476 }
477
478 fn cleanup(&mut self) -> io::Result<()> {
479 Ok(())
480 }
481
482 fn size(&self) -> io::Result<(u16, u16)> {
483 Ok((self.width, self.height))
484 }
485
486 fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
487 Ok(None)
488 }
489
490 fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
491 Ok(())
492 }
493
494 fn flush(&mut self) -> io::Result<()> {
495 Ok(())
496 }
497
498 fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
499 Ok(())
500 }
501
502 fn hide_cursor(&mut self) -> io::Result<()> {
503 Ok(())
504 }
505 }
506
507 fn fake_terminal(width: u16, height: u16) -> Terminal {
508 Terminal::with_backend(Box::new(FakeBackend { width, height }))
509 .expect("fake backend never fails to init")
510 }
511
512 #[derive(Clone, Default)]
522 struct RecordingBackend {
523 width: u16,
524 height: u16,
525 output: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
526 }
527
528 impl Backend for RecordingBackend {
529 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
530 self
531 }
532
533 fn init(&mut self) -> io::Result<()> {
534 Ok(())
535 }
536
537 fn cleanup(&mut self) -> io::Result<()> {
538 Ok(())
539 }
540
541 fn size(&self) -> io::Result<(u16, u16)> {
542 Ok((self.width, self.height))
543 }
544
545 fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
546 Ok(None)
547 }
548
549 fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
550 self.output.lock().unwrap().extend_from_slice(data);
551 Ok(())
552 }
553
554 fn flush(&mut self) -> io::Result<()> {
555 Ok(())
556 }
557
558 fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
559 Ok(())
560 }
561
562 fn hide_cursor(&mut self) -> io::Result<()> {
563 Ok(())
564 }
565 }
566
567 fn recording_terminal(
571 width: u16,
572 height: u16,
573 ) -> (Terminal, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
574 let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
575 let backend = RecordingBackend {
576 width,
577 height,
578 output: output.clone(),
579 };
580 let terminal =
581 Terminal::with_backend(Box::new(backend)).expect("fake backend never fails to init");
582 (terminal, output)
583 }
584
585 fn replay_onto_grid(bytes: &[u8], grid: &mut [Vec<char>]) {
596 let text = std::str::from_utf8(bytes).expect("flush emits valid UTF-8");
597 let mut chars = text.chars().peekable();
598 let mut row = 0usize;
599 let mut col = 0usize;
600 while let Some(c) = chars.next() {
601 if c == '\u{1b}' && chars.peek() == Some(&'[') {
602 chars.next(); let mut params = String::new();
604 let mut final_byte = ' ';
605 for pc in chars.by_ref() {
606 if pc.is_ascii_digit() || pc == ';' {
607 params.push(pc);
608 } else {
609 final_byte = pc;
610 break;
611 }
612 }
613 if final_byte == 'H' {
614 let mut parts = params.split(';');
615 let r: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
616 let cix: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
617 row = r.saturating_sub(1);
618 col = cix.saturating_sub(1);
619 }
620 continue;
622 }
623 let width = c.width().unwrap_or(0);
624 if row < grid.len() && col < grid[row].len() {
625 grid[row][col] = c;
626 }
627 col += width;
628 }
629 }
630
631 #[test]
632 fn scrollback_cap_drops_oldest_lines() {
633 let mut v = view();
634 v.set_max_lines(3);
635 for i in 0..5 {
636 v.push_line(&line(&i.to_string()));
637 }
638 assert_eq!(v.line_count(), 3);
639 assert_eq!(v.plain_text(), "2\n3\n4");
640 }
641
642 #[test]
643 fn autoscroll_holds_at_bottom_while_lines_arrive() {
644 let mut v = view();
645 for i in 0..50 {
646 v.push_line(&line(&i.to_string()));
647 }
648 assert!(v.is_at_bottom());
649 }
650
651 #[test]
652 fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
653 let mut v = view();
654 for i in 0..50 {
655 v.push_line(&line(&i.to_string()));
656 }
657 v.scroll_up(5);
658 assert!(!v.is_at_bottom());
659 v.push_line(&line("new"));
660 assert!(
661 !v.is_at_bottom(),
662 "a new line must not yank a scrolled-back reader to the bottom"
663 );
664 v.scroll_to_bottom();
665 assert!(v.is_at_bottom());
666 }
667
668 #[test]
669 fn partial_line_is_replaced_not_appended() {
670 let mut v = view();
671 v.set_partial(&line("par"));
672 v.set_partial(&line("part"));
673 assert_eq!(v.plain_text(), "part");
674 assert_eq!(v.line_count(), 1);
675 }
676
677 #[test]
678 fn plain_text_strips_attributes() {
679 let mut v = view();
680 v.push_line(&[Cell::new('x', Attr::new(TvColor::LightRed, TvColor::Blue))]);
681 assert_eq!(v.plain_text(), "x");
682 }
683
684 #[test]
685 fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
686 let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
687 for i in 0..50 {
688 v.push_line(&line(&i.to_string()));
689 }
690 v.scroll_to_top();
693 v.scroll_down(40);
694 assert!(!v.is_at_bottom());
695 let old_top = v.top;
696 assert!(old_top < v.max_top());
697
698 v.set_bounds(Rect::new(0, 0, 40, 48));
703
704 assert!(
705 v.top <= v.max_top(),
706 "top ({}) must not exceed max_top ({}) after growing",
707 v.top,
708 v.max_top()
709 );
710 let rows: Vec<&Vec<Cell>> = v.iter_rows().skip(v.top).take(v.page()).collect();
711 assert_eq!(
712 rows.len(),
713 v.page().min(v.row_count()),
714 "a full page of content should be visible after growing"
715 );
716 }
717
718 #[test]
719 fn draw_clips_to_bounds_width() {
720 let mut v = StreamView::new(Rect::new(2, 1, 8, 4));
721 v.push_line(&line("short")); let mut terminal = fake_terminal(20, 10);
724 v.draw(&mut terminal);
725
726 for (i, expected) in "short ".chars().enumerate() {
729 let cell = terminal
730 .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
731 .expect("cell within terminal bounds");
732 assert_eq!(cell.ch, expected);
733 }
734 assert_eq!(terminal.read_cell(8, 1).unwrap().ch, ' ');
736
737 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
739 }
740
741 const WRENCH: &str = "\u{1F6E0}\u{FE0F}";
751
752 #[test]
753 fn wide_character_row_paints_the_correct_total_number_of_columns() {
754 let mut v = StreamView::new(Rect::new(0, 0, 10, 4));
756 v.push_line(&line(&format!("{WRENCH} x")));
757 let mut terminal = fake_terminal(20, 10);
758 v.draw(&mut terminal);
759
760 assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
762 assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
766 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
768 assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'x');
769 for x in 4..10 {
771 assert_eq!(terminal.read_cell(x, 0).unwrap().ch, ' ');
772 }
773 }
774
775 #[test]
776 fn text_after_a_wide_character_lands_at_the_right_column() {
777 let mut v = StreamView::new(Rect::new(0, 0, 30, 4));
778 v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
779 let mut terminal = fake_terminal(30, 10);
780 v.draw(&mut terminal);
781
782 let expected = "\u{1F6E0}\u{FE0F} Reading src/dsml.rs";
783 for (i, expected_ch) in expected.chars().enumerate() {
784 let cell = terminal
785 .read_cell(i16::try_from(i).unwrap(), 0)
786 .expect("cell within terminal bounds");
787 assert_eq!(cell.ch, expected_ch, "column {i} mismatch");
788 }
789 }
790
791 #[test]
792 fn short_row_is_blank_padded_so_nothing_shows_through_from_beneath() {
793 let mut v = StreamView::new(Rect::new(0, 0, 10, 4));
794 v.push_line(&line("XXXXXXXXXX"));
796 let mut terminal = fake_terminal(20, 10);
797 v.draw(&mut terminal);
798 v.clear();
801 v.push_line(&line(&format!("{WRENCH}hi")));
802 v.draw(&mut terminal);
803
804 assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
805 assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
806 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, 'h');
807 assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'i');
808 for x in 4..10 {
809 assert_eq!(
810 terminal.read_cell(x, 0).unwrap().ch,
811 ' ',
812 "column {x} must be blanked, not left over from the previous row"
813 );
814 }
815 }
816
817 #[test]
818 fn a_double_width_character_straddling_a_wrap_boundary_is_never_split() {
819 let mut v = StreamView::new(Rect::new(0, 0, 3, 4));
825 v.push_line(&line("ab中cd"));
826
827 assert_eq!(v.row_count(), 3, "the 6-column line wraps to three rows");
828
829 let mut terminal = fake_terminal(20, 10);
830 v.draw(&mut terminal);
831
832 assert_eq!(terminal.read_cell(0, 0).unwrap().ch, 'a');
835 assert_eq!(terminal.read_cell(1, 0).unwrap().ch, 'b');
836
837 assert_eq!(terminal.read_cell(0, 1).unwrap().ch, '中');
839 assert_eq!(terminal.read_cell(1, 1).unwrap().ch, '\0');
840 assert_eq!(terminal.read_cell(2, 1).unwrap().ch, 'c');
841
842 assert_eq!(terminal.read_cell(0, 2).unwrap().ch, 'd');
844 }
845
846 #[test]
847 fn plain_text_round_trips_a_wide_character_with_no_padding_artifacts() {
848 let mut v = view();
849 v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
850 assert_eq!(v.plain_text(), format!("{WRENCH} Reading src/dsml.rs"));
851 }
852
853 #[test]
864 fn a_covering_window_s_flush_fully_blanks_a_row_that_held_a_wide_character() {
865 let (mut terminal, output) = recording_terminal(30, 4);
866 let mut grid = vec![vec![' '; 30]; 4];
867
868 let mut lower = StreamView::new(Rect::new(0, 0, 30, 4));
870 lower.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs 1:500...")));
871 lower.draw(&mut terminal);
872 terminal
873 .flush()
874 .expect("flush never fails against a fake backend");
875 replay_onto_grid(&output.lock().unwrap(), &mut grid);
876 output.lock().unwrap().clear();
877
878 let mut upper = StreamView::new(Rect::new(0, 0, 30, 4));
882 upper.draw(&mut terminal);
883 terminal
884 .flush()
885 .expect("flush never fails against a fake backend");
886 replay_onto_grid(&output.lock().unwrap(), &mut grid);
887
888 for (col, &ch) in grid[0].iter().enumerate() {
891 assert_eq!(
892 ch, ' ',
893 "row 0 column {col} still shows a leftover character from \
894 the window underneath: {grid:?}"
895 );
896 }
897 }
898
899 #[test]
900 fn a_line_longer_than_the_width_wraps_across_the_right_number_of_rows_with_complete_content() {
901 let mut v = StreamView::new(Rect::new(0, 0, 10, 20));
902 let text = "abcdefghijklmnopqrstuvwxy";
904 v.push_line(&line(text));
905
906 assert_eq!(v.row_count(), 3);
907 assert_eq!(
908 v.plain_text(),
909 text,
910 "wrapping must not drop or duplicate any character"
911 );
912
913 let mut terminal = fake_terminal(20, 20);
916 v.draw(&mut terminal);
917 let mut rendered = String::new();
918 for row in 0..3 {
919 for col in 0..10 {
920 rendered.push(terminal.read_cell(col, row).unwrap().ch);
921 }
922 }
923 assert_eq!(rendered, "abcdefghijklmnopqrstuvwxy ");
924 }
925
926 #[test]
927 fn a_wrap_breaks_at_a_space_rather_than_mid_word_when_one_is_available() {
928 let mut v = StreamView::new(Rect::new(0, 0, 10, 20));
929 v.push_line(&line("hello world"));
930
931 assert_eq!(v.row_count(), 2);
936 let mut terminal = fake_terminal(20, 20);
937 v.draw(&mut terminal);
938 for (i, expected) in "hello ".chars().enumerate() {
939 assert_eq!(
940 terminal.read_cell(i16::try_from(i).unwrap(), 0).unwrap().ch,
941 expected
942 );
943 }
944 for (i, expected) in "world ".chars().enumerate() {
945 assert_eq!(
946 terminal.read_cell(i16::try_from(i).unwrap(), 1).unwrap().ch,
947 expected
948 );
949 }
950 }
951
952 #[test]
953 fn a_single_token_longer_than_the_width_is_broken_rather_than_truncated() {
954 let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
955 v.push_line(&line("abcdefghijkl"));
959
960 assert_eq!(v.row_count(), 3); assert_eq!(
962 v.plain_text(),
963 "abcdefghijkl",
964 "the logical text is preserved even though it had to be broken mid-token"
965 );
966 }
967
968 #[test]
969 fn plain_text_returns_the_original_unwrapped_logical_lines() {
970 let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
971 v.push_line(&line("a much longer line than the five-column view"));
972 v.push_line(&line("short"));
973
974 assert_eq!(
975 v.plain_text(),
976 "a much longer line than the five-column view\nshort",
977 "Save As must get the original logical lines, not this window's wrap points"
978 );
979 }
980
981 #[test]
982 fn resizing_narrower_then_wider_rewraps_and_content_survives_both() {
983 let mut v = StreamView::new(Rect::new(0, 0, 20, 20));
984 let text = "abcdefghijklmnopqrstuvwxyz";
985 v.push_line(&line(text));
986 assert_eq!(v.row_count(), 2); v.set_bounds(Rect::new(0, 0, 5, 20));
989 assert_eq!(v.row_count(), 6); assert_eq!(v.plain_text(), text);
991
992 v.set_bounds(Rect::new(0, 0, 30, 20));
993 assert_eq!(v.row_count(), 1); assert_eq!(v.plain_text(), text);
995 }
996
997 #[test]
998 fn scrolling_by_page_lands_correctly_when_wrapped_rows_are_present() {
999 let mut v = StreamView::new(Rect::new(0, 0, 4, 5));
1001 let text: String = (0..80).map(|i| char::from(b'a' + (i % 26))).collect();
1002 v.push_line(&line(&text));
1003 assert_eq!(v.row_count(), 20);
1004
1005 v.scroll_to_top();
1006 assert_eq!(v.top, 0);
1007 v.scroll_down(v.page()); assert_eq!(
1009 v.top, 5,
1010 "paging must move by display rows, not logical lines"
1011 );
1012
1013 v.scroll_to_bottom();
1014 assert_eq!(v.top, v.row_count() - v.page());
1015 }
1016
1017 #[test]
1018 fn draw_on_zero_height_view_writes_nothing() {
1019 let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
1020 v.push_line(&line("hello"));
1021 let mut terminal = fake_terminal(20, 10);
1022 v.draw(&mut terminal);
1023 for y in 0..10 {
1024 for x in 0..20 {
1025 assert_eq!(
1026 terminal.read_cell(x, y).unwrap().ch,
1027 ' ',
1028 "zero-height view must not write any cell"
1029 );
1030 }
1031 }
1032 }
1033}