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_ESC, KB_HOME, KB_PGDN, KB_PGUP, KB_UP, MB_LEFT_BUTTON,
11};
12use turbo_vision::core::geometry::{Point, Rect};
13use turbo_vision::core::palette::{Attr, TvColor};
14use turbo_vision::core::state::{GF_GROW_HI_X, GF_GROW_HI_Y, GrowFlags};
15use turbo_vision::terminal::Terminal;
16use turbo_vision::views::view::{View, write_line_to_terminal};
17
18#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23struct SelPos {
24 row: usize,
25 col: usize,
26}
27
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30struct Selection {
31 anchor: SelPos,
32 head: SelPos,
33}
34
35fn reverse(attr: Attr) -> Attr {
38 Attr::new(attr.bg, attr.fg).with_style(attr.style)
39}
40
41fn order(a: SelPos, b: SelPos) -> (SelPos, SelPos) {
43 if (a.row, a.col) <= (b.row, b.col) {
44 (a, b)
45 } else {
46 (b, a)
47 }
48}
49
50const PRESENTATION_SELECTORS: [char; 2] = ['\u{FE0F}', '\u{FE0E}'];
62
63fn normalize_line(cells: &[Cell]) -> Vec<Cell> {
90 let mut out = Vec::with_capacity(cells.len());
91 let mut i = 0;
92 while i < cells.len() {
93 let cell = cells[i];
94 if cell.ch == '\0' {
95 out.push(cell);
96 i += 1;
97 continue;
98 }
99
100 let next = cells.get(i + 1).copied();
101 let selector = next.filter(|n| PRESENTATION_SELECTORS.contains(&n.ch));
102
103 let width = if let Some(sel) = selector {
104 let mut seq = String::with_capacity(cell.ch.len_utf8() + sel.ch.len_utf8());
105 seq.push(cell.ch);
106 seq.push(sel.ch);
107 seq.width()
108 } else {
109 cell.ch.width().unwrap_or(0)
110 };
111
112 if width == 0 {
113 i += if selector.is_some() { 2 } else { 1 };
114 continue;
115 }
116
117 out.push(cell);
118 if let Some(sel) = selector {
119 out.push(sel);
120 for _ in 2..width {
121 out.push(Cell::new('\0', cell.attr));
122 }
123 i += 2;
124 } else {
125 for _ in 1..width {
126 out.push(Cell::new('\0', cell.attr));
127 }
128 i += 1;
129 }
130 }
131 out
132}
133
134pub const DEFAULT_MAX_LINES: usize = 10_000;
136
137fn wrap_cells(cells: &[Cell], width: usize) -> Vec<Vec<Cell>> {
152 if width == 0 || cells.is_empty() {
153 return vec![cells.to_vec()];
154 }
155
156 let mut rows = Vec::new();
157 let mut rest = cells;
158 while rest.len() > width {
159 let mut break_at = None;
164 for i in (0..width).rev() {
165 if rest[i].ch.is_whitespace() {
166 break_at = Some(i);
167 break;
168 }
169 }
170 if let Some(i) = break_at {
171 rows.push(rest[..i].to_vec());
172 rest = &rest[i + 1..]; } else {
174 let mut cut = width;
179 if cut > 1 && rest.get(cut).is_some_and(|c| c.ch == '\0') {
180 cut -= 1;
181 }
182 rows.push(rest[..cut].to_vec());
183 rest = &rest[cut..];
184 }
185 }
186 rows.push(rest.to_vec());
187 rows
188}
189
190#[derive(Debug)]
193pub struct StreamView {
194 bounds: Rect,
195 grow_mode: GrowFlags,
203 lines: Vec<Vec<Cell>>,
208 partial: Option<Vec<Cell>>,
210 wrapped: Vec<Vec<Cell>>,
214 row_counts: Vec<usize>,
218 partial_wrapped: Vec<Vec<Cell>>,
222 max_lines: usize,
228 top: usize,
230 follow: bool,
232 fill: Attr,
233 selection: Option<Selection>,
237}
238
239impl StreamView {
240 #[must_use]
241 pub fn new(bounds: Rect) -> Self {
242 Self {
243 bounds,
244 grow_mode: GF_GROW_HI_X | GF_GROW_HI_Y,
245 lines: Vec::new(),
246 partial: None,
247 wrapped: Vec::new(),
248 row_counts: Vec::new(),
249 partial_wrapped: Vec::new(),
250 max_lines: DEFAULT_MAX_LINES,
251 top: 0,
252 follow: true,
253 fill: Attr::new(TvColor::LightGray, TvColor::Black),
254 selection: None,
255 }
256 }
257
258 fn width(&self) -> usize {
259 usize::try_from(self.bounds.width()).unwrap_or(0)
260 }
261
262 pub fn set_max_lines(&mut self, n: usize) {
263 self.max_lines = n.max(1);
264 self.trim();
265 }
266
267 pub fn push_line(&mut self, cells: &[Cell]) {
269 self.selection = None;
272 let normalized = normalize_line(cells);
273 let rows = wrap_cells(&normalized, self.width());
274 self.row_counts.push(rows.len());
275 self.wrapped.extend(rows);
276 self.lines.push(normalized);
277 self.trim();
278 if self.follow {
279 self.scroll_to_bottom();
280 }
281 }
282
283 pub fn set_partial(&mut self, cells: &[Cell]) {
286 let cells = normalize_line(cells);
287 if cells.is_empty() {
288 self.partial = None;
289 self.partial_wrapped.clear();
290 } else {
291 self.partial_wrapped = wrap_cells(&cells, self.width());
292 self.partial = Some(cells);
293 }
294 if self.follow {
295 self.scroll_to_bottom();
296 }
297 }
298
299 pub fn clear(&mut self) {
300 self.lines.clear();
301 self.partial = None;
302 self.wrapped.clear();
303 self.row_counts.clear();
304 self.partial_wrapped.clear();
305 self.top = 0;
306 self.follow = true;
307 self.selection = None;
308 }
309
310 #[must_use]
312 pub fn line_count(&self) -> usize {
313 self.lines.len() + usize::from(self.partial.is_some())
314 }
315
316 #[must_use]
321 pub fn row_count(&self) -> usize {
322 self.wrapped.len() + self.partial_wrapped.len()
323 }
324
325 fn page(&self) -> usize {
327 usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
328 }
329
330 fn max_top(&self) -> usize {
331 self.row_count().saturating_sub(self.page())
332 }
333
334 fn rewrap(&mut self) {
339 let width = self.width();
340 self.wrapped.clear();
341 self.row_counts.clear();
342 for line in &self.lines {
343 let rows = wrap_cells(line, width);
344 self.row_counts.push(rows.len());
345 self.wrapped.extend(rows);
346 }
347 self.partial_wrapped = match &self.partial {
348 Some(cells) => wrap_cells(cells, width),
349 None => Vec::new(),
350 };
351 }
352
353 pub fn scroll_to_bottom(&mut self) {
354 self.top = self.max_top();
355 self.follow = true;
356 }
357
358 pub fn scroll_to_top(&mut self) {
359 self.top = 0;
360 self.follow = false;
361 }
362
363 pub fn scroll_up(&mut self, n: usize) {
364 self.top = self.top.saturating_sub(n);
365 self.follow = false;
366 }
367
368 pub fn scroll_down(&mut self, n: usize) {
369 self.top = (self.top + n).min(self.max_top());
370 self.follow = self.top == self.max_top();
371 }
372
373 #[must_use]
374 pub fn is_at_bottom(&self) -> bool {
375 self.follow
376 }
377
378 pub fn select_all(&mut self) {
383 let rows = self.row_count();
384 if rows == 0 {
385 self.selection = None;
386 return;
387 }
388 let last = rows - 1;
389 let last_len = self.row_at(last).map_or(0, Vec::len);
390 self.selection = Some(Selection {
391 anchor: SelPos { row: 0, col: 0 },
392 head: SelPos {
393 row: last,
394 col: last_len,
395 },
396 });
397 }
398
399 pub fn set_selection(&mut self, anchor: (usize, usize), head: (usize, usize)) {
402 self.selection = Some(Selection {
403 anchor: SelPos {
404 row: anchor.0,
405 col: anchor.1,
406 },
407 head: SelPos {
408 row: head.0,
409 col: head.1,
410 },
411 });
412 }
413
414 pub fn clear_selection(&mut self) {
415 self.selection = None;
416 }
417
418 #[must_use]
419 pub fn has_selection(&self) -> bool {
420 self.selection.is_some()
421 }
422
423 #[must_use]
426 pub fn selected_text(&self) -> Option<String> {
427 let sel = self.selection?;
428 let (start, end) = order(sel.anchor, sel.head);
429 let mut out = String::new();
430 for row in start.row..=end.row {
431 let Some(cells) = self.row_at(row) else {
432 continue;
433 };
434 let from = if row == start.row { start.col } else { 0 }.min(cells.len());
435 let to = if row == end.row { end.col } else { cells.len() }.min(cells.len());
436 if row > start.row && self.row_is_logical_start(row) {
437 out.push('\n');
438 }
439 out.extend(cells[from..to.max(from)].iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
440 }
441 Some(out)
442 }
443
444 fn row_at(&self, idx: usize) -> Option<&Vec<Cell>> {
445 self.iter_rows().nth(idx)
446 }
447
448 fn hit(&self, pos: Point) -> Option<SelPos> {
452 let x = usize::try_from(pos.x - self.bounds.a.x).ok()?;
453 let y = usize::try_from(pos.y - self.bounds.a.y).ok()?;
454 if y >= self.page() {
455 return None;
456 }
457 let abs_row = self.top + y;
458 let len = self.row_at(abs_row)?.len();
459 Some(SelPos {
460 row: abs_row,
461 col: x.min(len),
462 })
463 }
464
465 fn is_selected(&self, abs_row: usize, col: usize) -> bool {
468 let Some(sel) = self.selection else {
469 return false;
470 };
471 let (s, e) = order(sel.anchor, sel.head);
472 (abs_row, col) >= (s.row, s.col) && (abs_row, col) < (e.row, e.col)
473 }
474
475 fn row_is_logical_start(&self, abs_row: usize) -> bool {
478 let mut offset = 0;
479 for &count in &self.row_counts {
480 if abs_row == offset {
481 return true;
482 }
483 offset += count;
484 }
485 abs_row == offset
487 }
488
489 #[must_use]
491 pub fn plain_text(&self) -> String {
492 let mut out = String::new();
493 for (i, line) in self.iter_lines().enumerate() {
494 if i > 0 {
495 out.push('\n');
496 }
497 out.extend(line.iter().map(|c| c.ch).filter(|&ch| ch != '\0'));
501 }
502 out
503 }
504
505 #[must_use]
507 pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
508 self.iter_lines().cloned().collect()
509 }
510
511 fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
512 self.lines.iter().chain(self.partial.iter())
513 }
514
515 fn iter_rows(&self) -> impl Iterator<Item = &Vec<Cell>> {
518 self.wrapped.iter().chain(self.partial_wrapped.iter())
519 }
520
521 fn trim(&mut self) {
525 if self.lines.len() > self.max_lines {
526 let drop = self.lines.len() - self.max_lines;
527 self.lines.drain(..drop);
528 let dropped_rows: usize = self.row_counts.drain(..drop).sum();
529 self.wrapped.drain(..dropped_rows);
530 self.top = self.top.saturating_sub(dropped_rows);
531 }
532 }
533}
534
535impl View for StreamView {
536 fn bounds(&self) -> Rect {
537 self.bounds
538 }
539
540 fn set_bounds(&mut self, bounds: Rect) {
541 let width_changed = self.width() != usize::try_from(bounds.width()).unwrap_or(0);
542 self.bounds = bounds;
543 if width_changed {
544 self.rewrap();
545 }
546 if self.follow {
547 self.scroll_to_bottom();
548 } else {
549 self.top = self.top.min(self.max_top());
550 }
551 }
552
553 fn draw(&mut self, terminal: &mut Terminal) {
554 if self.bounds.height() <= 0 {
555 return;
556 }
557 let width = usize::try_from(self.bounds.width()).unwrap_or(0);
558 let page = self.page();
559 let rows: Vec<&Vec<Cell>> = self.iter_rows().skip(self.top).take(page).collect();
560
561 for row in 0..page {
562 let mut buf = DrawBuffer::new(width);
563 for i in 0..width {
564 buf.put_char(i, ' ', self.fill);
565 }
566 if let Some(line) = rows.get(row) {
567 let abs_row = self.top + row;
568 for (i, cell) in line.iter().take(width).enumerate() {
569 let attr = if self.is_selected(abs_row, i) {
570 reverse(cell.attr)
571 } else {
572 cell.attr
573 };
574 buf.put_char(i, cell.ch, attr);
575 }
576 }
577 let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
578 write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
579 }
580 }
581
582 fn handle_event(&mut self, event: &mut Event) {
583 match event.what {
584 EventType::Keyboard => {
585 let page = self.page();
586 match event.key_code {
587 KB_UP => self.scroll_up(1),
588 KB_DOWN => self.scroll_down(1),
589 KB_PGUP => self.scroll_up(page),
590 KB_PGDN => self.scroll_down(page),
591 KB_HOME => self.scroll_to_top(),
592 KB_END => self.scroll_to_bottom(),
593 KB_ESC if self.selection.is_some() => self.clear_selection(),
594 _ => return,
595 }
596 event.clear();
597 }
598 EventType::MouseDown if event.mouse.buttons & MB_LEFT_BUTTON != 0 => {
599 let Some(pos) = self.hit(event.mouse.pos) else {
600 return;
601 };
602 self.selection = Some(Selection {
603 anchor: pos,
604 head: pos,
605 });
606 event.clear();
607 }
608 EventType::MouseMove | EventType::MouseAuto
609 if event.mouse.buttons & MB_LEFT_BUTTON != 0 =>
610 {
611 if let (Some(mut sel), Some(pos)) = (self.selection, self.hit(event.mouse.pos)) {
612 sel.head = pos;
613 self.selection = Some(sel);
614 event.clear();
615 }
616 }
617 EventType::MouseUp => {
618 if let Some(sel) = self.selection
621 && sel.anchor == sel.head
622 {
623 self.selection = None;
624 }
625 event.clear();
626 }
627 _ => {}
628 }
629 }
630
631 fn grow_mode(&self) -> GrowFlags {
632 self.grow_mode
633 }
634
635 fn set_grow_mode(&mut self, grow_mode: GrowFlags) {
636 self.grow_mode = grow_mode;
637 }
638
639 fn can_focus(&self) -> bool {
640 true
641 }
642
643 fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
644 None
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use std::io;
654 use std::time::Duration;
655 use turbo_vision::core::palette::TvColor;
656 use turbo_vision::terminal::Backend;
657
658 fn line(s: &str) -> Vec<Cell> {
659 s.chars()
660 .map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
661 .collect()
662 }
663
664 fn view() -> StreamView {
665 StreamView::new(Rect::new(0, 0, 40, 10))
666 }
667
668 #[test]
669 fn select_all_extracts_logical_lines_without_soft_wrap_newlines() {
670 let mut v = StreamView::new(Rect::new(0, 0, 10, 10));
671 v.push_line(&line("hello"));
672 v.push_line(&line("abcdefghijABCDEFGHIJ")); v.select_all();
674 assert_eq!(
675 v.selected_text().unwrap(),
676 "hello\nabcdefghijABCDEFGHIJ",
677 "soft wraps within a logical line must not become newlines"
678 );
679 }
680
681 #[test]
682 fn stream_selection_spans_from_anchor_to_head_across_a_line_break() {
683 let mut v = view();
684 v.push_line(&line("hello"));
685 v.push_line(&line("world"));
686 v.set_selection((0, 2), (1, 3));
688 assert_eq!(v.selected_text().unwrap(), "llo\nwor");
689 }
690
691 #[test]
692 fn stream_selection_is_order_independent() {
693 let mut v = view();
694 v.push_line(&line("hello"));
695 v.push_line(&line("world"));
696 v.set_selection((1, 3), (0, 2)); assert_eq!(v.selected_text().unwrap(), "llo\nwor");
698 }
699
700 #[test]
701 fn no_selection_yields_no_text() {
702 let mut v = view();
703 v.push_line(&line("hello"));
704 assert!(v.selected_text().is_none());
705 assert!(!v.has_selection());
706 }
707
708 #[test]
709 fn selected_cells_render_reverse_video() {
710 let mut v = StreamView::new(Rect::new(0, 0, 8, 4));
711 v.push_line(&line("abcd"));
712 v.set_selection((0, 1), (0, 3)); let mut terminal = fake_terminal(20, 10);
714 v.draw(&mut terminal);
715 let a = terminal.read_cell(0, 0).unwrap(); let b = terminal.read_cell(1, 0).unwrap(); assert_eq!(b.ch, 'b');
718 assert_eq!(b.attr.fg, a.attr.bg, "selected fg is the normal bg");
719 assert_eq!(b.attr.bg, a.attr.fg, "selected bg is the normal fg");
720 let d = terminal.read_cell(3, 0).unwrap();
722 assert_eq!(d.attr.fg, a.attr.fg, "col 3 is outside [1,3), so normal");
723 }
724
725 #[test]
726 fn mouse_drag_creates_a_stream_selection() {
727 let mut v = view();
728 v.push_line(&line("hello"));
729 v.push_line(&line("world"));
730 let mut down = Event::mouse(EventType::MouseDown, Point::new(2, 0), MB_LEFT_BUTTON, false);
731 v.handle_event(&mut down);
732 let mut mv = Event::mouse(EventType::MouseMove, Point::new(3, 1), MB_LEFT_BUTTON, false);
733 v.handle_event(&mut mv);
734 let mut up = Event::mouse(EventType::MouseUp, Point::new(3, 1), 0, false);
735 v.handle_event(&mut up);
736 assert_eq!(v.selected_text().unwrap(), "llo\nwor");
737 }
738
739 #[test]
740 fn a_plain_click_clears_any_selection() {
741 let mut v = view();
742 v.push_line(&line("hello"));
743 v.select_all();
744 assert!(v.has_selection());
745 let mut down = Event::mouse(EventType::MouseDown, Point::new(2, 0), MB_LEFT_BUTTON, false);
746 v.handle_event(&mut down);
747 let mut up = Event::mouse(EventType::MouseUp, Point::new(2, 0), 0, false);
748 v.handle_event(&mut up);
749 assert!(!v.has_selection(), "click without drag deselects");
750 }
751
752 #[test]
753 fn esc_clears_the_selection() {
754 let mut v = view();
755 v.push_line(&line("hello"));
756 v.select_all();
757 let mut esc = Event::keyboard(KB_ESC);
758 v.handle_event(&mut esc);
759 assert!(!v.has_selection());
760 }
761
762 #[test]
763 fn mutating_the_buffer_clears_the_selection() {
764 let mut v = view();
765 v.push_line(&line("hello"));
766 v.select_all();
767 assert!(v.has_selection());
768 v.push_line(&line("more"));
769 assert!(!v.has_selection(), "new content must drop a stale selection");
770 }
771
772 struct FakeBackend {
777 width: u16,
778 height: u16,
779 }
780
781 impl Backend for FakeBackend {
782 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
783 self
784 }
785
786 fn init(&mut self) -> io::Result<()> {
787 Ok(())
788 }
789
790 fn cleanup(&mut self) -> io::Result<()> {
791 Ok(())
792 }
793
794 fn size(&self) -> io::Result<(u16, u16)> {
795 Ok((self.width, self.height))
796 }
797
798 fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
799 Ok(None)
800 }
801
802 fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
803 Ok(())
804 }
805
806 fn flush(&mut self) -> io::Result<()> {
807 Ok(())
808 }
809
810 fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
811 Ok(())
812 }
813
814 fn hide_cursor(&mut self) -> io::Result<()> {
815 Ok(())
816 }
817 }
818
819 fn fake_terminal(width: u16, height: u16) -> Terminal {
820 Terminal::with_backend(Box::new(FakeBackend { width, height }))
821 .expect("fake backend never fails to init")
822 }
823
824 #[derive(Clone, Default)]
834 struct RecordingBackend {
835 width: u16,
836 height: u16,
837 output: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
838 }
839
840 impl Backend for RecordingBackend {
841 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
842 self
843 }
844
845 fn init(&mut self) -> io::Result<()> {
846 Ok(())
847 }
848
849 fn cleanup(&mut self) -> io::Result<()> {
850 Ok(())
851 }
852
853 fn size(&self) -> io::Result<(u16, u16)> {
854 Ok((self.width, self.height))
855 }
856
857 fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
858 Ok(None)
859 }
860
861 fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
862 self.output.lock().unwrap().extend_from_slice(data);
863 Ok(())
864 }
865
866 fn flush(&mut self) -> io::Result<()> {
867 Ok(())
868 }
869
870 fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
871 Ok(())
872 }
873
874 fn hide_cursor(&mut self) -> io::Result<()> {
875 Ok(())
876 }
877 }
878
879 fn recording_terminal(
883 width: u16,
884 height: u16,
885 ) -> (Terminal, std::sync::Arc<std::sync::Mutex<Vec<u8>>>) {
886 let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
887 let backend = RecordingBackend {
888 width,
889 height,
890 output: output.clone(),
891 };
892 let terminal =
893 Terminal::with_backend(Box::new(backend)).expect("fake backend never fails to init");
894 (terminal, output)
895 }
896
897 fn replay_onto_grid(bytes: &[u8], grid: &mut [Vec<char>]) {
908 let text = std::str::from_utf8(bytes).expect("flush emits valid UTF-8");
909 let mut chars = text.chars().peekable();
910 let mut row = 0usize;
911 let mut col = 0usize;
912 while let Some(c) = chars.next() {
913 if c == '\u{1b}' && chars.peek() == Some(&'[') {
914 chars.next(); let mut params = String::new();
916 let mut final_byte = ' ';
917 for pc in chars.by_ref() {
918 if pc.is_ascii_digit() || pc == ';' {
919 params.push(pc);
920 } else {
921 final_byte = pc;
922 break;
923 }
924 }
925 if final_byte == 'H' {
926 let mut parts = params.split(';');
927 let r: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
928 let cix: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
929 row = r.saturating_sub(1);
930 col = cix.saturating_sub(1);
931 }
932 continue;
934 }
935 let width = c.width().unwrap_or(0);
936 if row < grid.len() && col < grid[row].len() {
937 grid[row][col] = c;
938 }
939 col += width;
940 }
941 }
942
943 #[test]
944 fn scrollback_cap_drops_oldest_lines() {
945 let mut v = view();
946 v.set_max_lines(3);
947 for i in 0..5 {
948 v.push_line(&line(&i.to_string()));
949 }
950 assert_eq!(v.line_count(), 3);
951 assert_eq!(v.plain_text(), "2\n3\n4");
952 }
953
954 #[test]
955 fn autoscroll_holds_at_bottom_while_lines_arrive() {
956 let mut v = view();
957 for i in 0..50 {
958 v.push_line(&line(&i.to_string()));
959 }
960 assert!(v.is_at_bottom());
961 }
962
963 #[test]
964 fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
965 let mut v = view();
966 for i in 0..50 {
967 v.push_line(&line(&i.to_string()));
968 }
969 v.scroll_up(5);
970 assert!(!v.is_at_bottom());
971 v.push_line(&line("new"));
972 assert!(
973 !v.is_at_bottom(),
974 "a new line must not yank a scrolled-back reader to the bottom"
975 );
976 v.scroll_to_bottom();
977 assert!(v.is_at_bottom());
978 }
979
980 #[test]
981 fn partial_line_is_replaced_not_appended() {
982 let mut v = view();
983 v.set_partial(&line("par"));
984 v.set_partial(&line("part"));
985 assert_eq!(v.plain_text(), "part");
986 assert_eq!(v.line_count(), 1);
987 }
988
989 #[test]
990 fn plain_text_strips_attributes() {
991 let mut v = view();
992 v.push_line(&[Cell::new('x', Attr::new(TvColor::LightRed, TvColor::Blue))]);
993 assert_eq!(v.plain_text(), "x");
994 }
995
996 #[test]
997 fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
998 let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
999 for i in 0..50 {
1000 v.push_line(&line(&i.to_string()));
1001 }
1002 v.scroll_to_top();
1005 v.scroll_down(40);
1006 assert!(!v.is_at_bottom());
1007 let old_top = v.top;
1008 assert!(old_top < v.max_top());
1009
1010 v.set_bounds(Rect::new(0, 0, 40, 48));
1015
1016 assert!(
1017 v.top <= v.max_top(),
1018 "top ({}) must not exceed max_top ({}) after growing",
1019 v.top,
1020 v.max_top()
1021 );
1022 let rows: Vec<&Vec<Cell>> = v.iter_rows().skip(v.top).take(v.page()).collect();
1023 assert_eq!(
1024 rows.len(),
1025 v.page().min(v.row_count()),
1026 "a full page of content should be visible after growing"
1027 );
1028 }
1029
1030 #[test]
1031 fn draw_clips_to_bounds_width() {
1032 let mut v = StreamView::new(Rect::new(2, 1, 8, 4));
1033 v.push_line(&line("short")); let mut terminal = fake_terminal(20, 10);
1036 v.draw(&mut terminal);
1037
1038 for (i, expected) in "short ".chars().enumerate() {
1041 let cell = terminal
1042 .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
1043 .expect("cell within terminal bounds");
1044 assert_eq!(cell.ch, expected);
1045 }
1046 assert_eq!(terminal.read_cell(8, 1).unwrap().ch, ' ');
1048
1049 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
1051 }
1052
1053 const WRENCH: &str = "\u{1F6E0}\u{FE0F}";
1063
1064 #[test]
1065 fn wide_character_row_paints_the_correct_total_number_of_columns() {
1066 let mut v = StreamView::new(Rect::new(0, 0, 10, 4));
1068 v.push_line(&line(&format!("{WRENCH} x")));
1069 let mut terminal = fake_terminal(20, 10);
1070 v.draw(&mut terminal);
1071
1072 assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
1074 assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
1078 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
1080 assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'x');
1081 for x in 4..10 {
1083 assert_eq!(terminal.read_cell(x, 0).unwrap().ch, ' ');
1084 }
1085 }
1086
1087 #[test]
1088 fn text_after_a_wide_character_lands_at_the_right_column() {
1089 let mut v = StreamView::new(Rect::new(0, 0, 30, 4));
1090 v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
1091 let mut terminal = fake_terminal(30, 10);
1092 v.draw(&mut terminal);
1093
1094 let expected = "\u{1F6E0}\u{FE0F} Reading src/dsml.rs";
1095 for (i, expected_ch) in expected.chars().enumerate() {
1096 let cell = terminal
1097 .read_cell(i16::try_from(i).unwrap(), 0)
1098 .expect("cell within terminal bounds");
1099 assert_eq!(cell.ch, expected_ch, "column {i} mismatch");
1100 }
1101 }
1102
1103 #[test]
1104 fn short_row_is_blank_padded_so_nothing_shows_through_from_beneath() {
1105 let mut v = StreamView::new(Rect::new(0, 0, 10, 4));
1106 v.push_line(&line("XXXXXXXXXX"));
1108 let mut terminal = fake_terminal(20, 10);
1109 v.draw(&mut terminal);
1110 v.clear();
1113 v.push_line(&line(&format!("{WRENCH}hi")));
1114 v.draw(&mut terminal);
1115
1116 assert_eq!(terminal.read_cell(0, 0).unwrap().ch, '\u{1F6E0}');
1117 assert_eq!(terminal.read_cell(1, 0).unwrap().ch, '\u{FE0F}');
1118 assert_eq!(terminal.read_cell(2, 0).unwrap().ch, 'h');
1119 assert_eq!(terminal.read_cell(3, 0).unwrap().ch, 'i');
1120 for x in 4..10 {
1121 assert_eq!(
1122 terminal.read_cell(x, 0).unwrap().ch,
1123 ' ',
1124 "column {x} must be blanked, not left over from the previous row"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn a_double_width_character_straddling_a_wrap_boundary_is_never_split() {
1131 let mut v = StreamView::new(Rect::new(0, 0, 3, 4));
1137 v.push_line(&line("ab中cd"));
1138
1139 assert_eq!(v.row_count(), 3, "the 6-column line wraps to three rows");
1140
1141 let mut terminal = fake_terminal(20, 10);
1142 v.draw(&mut terminal);
1143
1144 assert_eq!(terminal.read_cell(0, 0).unwrap().ch, 'a');
1147 assert_eq!(terminal.read_cell(1, 0).unwrap().ch, 'b');
1148
1149 assert_eq!(terminal.read_cell(0, 1).unwrap().ch, '中');
1151 assert_eq!(terminal.read_cell(1, 1).unwrap().ch, '\0');
1152 assert_eq!(terminal.read_cell(2, 1).unwrap().ch, 'c');
1153
1154 assert_eq!(terminal.read_cell(0, 2).unwrap().ch, 'd');
1156 }
1157
1158 #[test]
1159 fn plain_text_round_trips_a_wide_character_with_no_padding_artifacts() {
1160 let mut v = view();
1161 v.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs")));
1162 assert_eq!(v.plain_text(), format!("{WRENCH} Reading src/dsml.rs"));
1163 }
1164
1165 #[test]
1176 fn a_covering_window_s_flush_fully_blanks_a_row_that_held_a_wide_character() {
1177 let (mut terminal, output) = recording_terminal(30, 4);
1178 let mut grid = vec![vec![' '; 30]; 4];
1179
1180 let mut lower = StreamView::new(Rect::new(0, 0, 30, 4));
1182 lower.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs 1:500...")));
1183 lower.draw(&mut terminal);
1184 terminal
1185 .flush()
1186 .expect("flush never fails against a fake backend");
1187 replay_onto_grid(&output.lock().unwrap(), &mut grid);
1188 output.lock().unwrap().clear();
1189
1190 let mut upper = StreamView::new(Rect::new(0, 0, 30, 4));
1194 upper.draw(&mut terminal);
1195 terminal
1196 .flush()
1197 .expect("flush never fails against a fake backend");
1198 replay_onto_grid(&output.lock().unwrap(), &mut grid);
1199
1200 for (col, &ch) in grid[0].iter().enumerate() {
1203 assert_eq!(
1204 ch, ' ',
1205 "row 0 column {col} still shows a leftover character from \
1206 the window underneath: {grid:?}"
1207 );
1208 }
1209 }
1210
1211 #[test]
1212 fn a_line_longer_than_the_width_wraps_across_the_right_number_of_rows_with_complete_content() {
1213 let mut v = StreamView::new(Rect::new(0, 0, 10, 20));
1214 let text = "abcdefghijklmnopqrstuvwxy";
1216 v.push_line(&line(text));
1217
1218 assert_eq!(v.row_count(), 3);
1219 assert_eq!(
1220 v.plain_text(),
1221 text,
1222 "wrapping must not drop or duplicate any character"
1223 );
1224
1225 let mut terminal = fake_terminal(20, 20);
1228 v.draw(&mut terminal);
1229 let mut rendered = String::new();
1230 for row in 0..3 {
1231 for col in 0..10 {
1232 rendered.push(terminal.read_cell(col, row).unwrap().ch);
1233 }
1234 }
1235 assert_eq!(rendered, "abcdefghijklmnopqrstuvwxy ");
1236 }
1237
1238 #[test]
1239 fn a_wrap_breaks_at_a_space_rather_than_mid_word_when_one_is_available() {
1240 let mut v = StreamView::new(Rect::new(0, 0, 10, 20));
1241 v.push_line(&line("hello world"));
1242
1243 assert_eq!(v.row_count(), 2);
1248 let mut terminal = fake_terminal(20, 20);
1249 v.draw(&mut terminal);
1250 for (i, expected) in "hello ".chars().enumerate() {
1251 assert_eq!(
1252 terminal.read_cell(i16::try_from(i).unwrap(), 0).unwrap().ch,
1253 expected
1254 );
1255 }
1256 for (i, expected) in "world ".chars().enumerate() {
1257 assert_eq!(
1258 terminal.read_cell(i16::try_from(i).unwrap(), 1).unwrap().ch,
1259 expected
1260 );
1261 }
1262 }
1263
1264 #[test]
1265 fn a_single_token_longer_than_the_width_is_broken_rather_than_truncated() {
1266 let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
1267 v.push_line(&line("abcdefghijkl"));
1271
1272 assert_eq!(v.row_count(), 3); assert_eq!(
1274 v.plain_text(),
1275 "abcdefghijkl",
1276 "the logical text is preserved even though it had to be broken mid-token"
1277 );
1278 }
1279
1280 #[test]
1281 fn plain_text_returns_the_original_unwrapped_logical_lines() {
1282 let mut v = StreamView::new(Rect::new(0, 0, 5, 20));
1283 v.push_line(&line("a much longer line than the five-column view"));
1284 v.push_line(&line("short"));
1285
1286 assert_eq!(
1287 v.plain_text(),
1288 "a much longer line than the five-column view\nshort",
1289 "Save As must get the original logical lines, not this window's wrap points"
1290 );
1291 }
1292
1293 #[test]
1294 fn resizing_narrower_then_wider_rewraps_and_content_survives_both() {
1295 let mut v = StreamView::new(Rect::new(0, 0, 20, 20));
1296 let text = "abcdefghijklmnopqrstuvwxyz";
1297 v.push_line(&line(text));
1298 assert_eq!(v.row_count(), 2); v.set_bounds(Rect::new(0, 0, 5, 20));
1301 assert_eq!(v.row_count(), 6); assert_eq!(v.plain_text(), text);
1303
1304 v.set_bounds(Rect::new(0, 0, 30, 20));
1305 assert_eq!(v.row_count(), 1); assert_eq!(v.plain_text(), text);
1307 }
1308
1309 #[test]
1310 fn scrolling_by_page_lands_correctly_when_wrapped_rows_are_present() {
1311 let mut v = StreamView::new(Rect::new(0, 0, 4, 5));
1313 let text: String = (0..80).map(|i| char::from(b'a' + (i % 26))).collect();
1314 v.push_line(&line(&text));
1315 assert_eq!(v.row_count(), 20);
1316
1317 v.scroll_to_top();
1318 assert_eq!(v.top, 0);
1319 v.scroll_down(v.page()); assert_eq!(
1321 v.top, 5,
1322 "paging must move by display rows, not logical lines"
1323 );
1324
1325 v.scroll_to_bottom();
1326 assert_eq!(v.top, v.row_count() - v.page());
1327 }
1328
1329 #[test]
1330 fn draw_on_zero_height_view_writes_nothing() {
1331 let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
1332 v.push_line(&line("hello"));
1333 let mut terminal = fake_terminal(20, 10);
1334 v.draw(&mut terminal);
1335 for y in 0..10 {
1336 for x in 0..20 {
1337 assert_eq!(
1338 terminal.read_cell(x, y).unwrap().ch,
1339 ' ',
1340 "zero-height view must not write any cell"
1341 );
1342 }
1343 }
1344 }
1345}