1use ratatui::buffer::Buffer as TermBuffer;
27use ratatui::layout::Rect;
28use ratatui::style::Style;
29use ratatui::widgets::Widget;
30use unicode_width::UnicodeWidthChar;
31
32use crate::wrap::wrap_segments;
33use crate::{Buffer, Selection, Span, Viewport, Wrap};
34
35pub trait StyleResolver {
39 fn resolve(&self, style_id: u32) -> Style;
40}
41
42impl<F: Fn(u32) -> Style> StyleResolver for F {
44 fn resolve(&self, style_id: u32) -> Style {
45 self(style_id)
46 }
47}
48
49pub struct BufferView<'a, R: StyleResolver> {
64 pub buffer: &'a Buffer,
65 pub viewport: &'a Viewport,
69 pub selection: Option<Selection>,
70 pub resolver: &'a R,
71 pub cursor_line_bg: Style,
74 pub cursor_column_bg: Style,
77 pub selection_bg: Style,
79 pub cursor_style: Style,
82 pub gutter: Option<Gutter>,
86 pub search_bg: Style,
89 pub signs: &'a [Sign],
94 pub conceals: &'a [Conceal],
98 pub spans: &'a [Vec<Span>],
107 pub search_pattern: Option<&'a regex::Regex>,
115}
116
117#[derive(Debug, Clone, Copy, Default)]
127pub struct Gutter {
128 pub width: u16,
129 pub style: Style,
130 pub line_offset: usize,
131}
132
133#[derive(Debug, Clone, Copy)]
138pub struct Sign {
139 pub row: usize,
140 pub ch: char,
141 pub style: Style,
142 pub priority: u8,
143}
144
145#[derive(Debug, Clone)]
150pub struct Conceal {
151 pub row: usize,
152 pub start_byte: usize,
153 pub end_byte: usize,
154 pub replacement: String,
155}
156
157impl<R: StyleResolver> Widget for BufferView<'_, R> {
158 fn render(self, area: Rect, term_buf: &mut TermBuffer) {
159 let viewport = *self.viewport;
160 let cursor = self.buffer.cursor();
161 let lines = self.buffer.lines();
162 let spans = self.spans;
163 let folds = self.buffer.folds();
164 let top_row = viewport.top_row;
165 let top_col = viewport.top_col;
166
167 let gutter_width = self.gutter.map(|g| g.width).unwrap_or(0);
168 let text_area = Rect {
169 x: area.x.saturating_add(gutter_width),
170 y: area.y,
171 width: area.width.saturating_sub(gutter_width),
172 height: area.height,
173 };
174
175 let total_rows = lines.len();
176 let mut doc_row = top_row;
177 let mut screen_row: u16 = 0;
178 let wrap_mode = viewport.wrap;
179 let seg_width = if viewport.text_width > 0 {
180 viewport.text_width
181 } else {
182 text_area.width
183 };
184 let mut search_hit_at_cursor_col: Vec<bool> = Vec::new();
190 while doc_row < total_rows && screen_row < area.height {
194 if folds.iter().any(|f| f.hides(doc_row)) {
197 doc_row += 1;
198 continue;
199 }
200 let folded_at_start = folds
201 .iter()
202 .find(|f| f.closed && f.start_row == doc_row)
203 .copied();
204 let line = &lines[doc_row];
205 let row_spans = spans.get(doc_row).map(Vec::as_slice).unwrap_or(&[]);
206 let sel_range = self.selection.and_then(|s| s.row_span(doc_row));
207 let is_cursor_row = doc_row == cursor.row;
208 if let Some(fold) = folded_at_start {
209 if let Some(gutter) = self.gutter {
210 self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
211 self.paint_signs(term_buf, area, screen_row, doc_row);
212 }
213 self.paint_fold_marker(term_buf, text_area, screen_row, fold, line, is_cursor_row);
214 search_hit_at_cursor_col.push(false);
215 screen_row += 1;
216 doc_row = fold.end_row + 1;
217 continue;
218 }
219 let search_ranges = self.row_search_ranges(line);
220 let row_has_hit_at_cursor_col = search_ranges
221 .iter()
222 .any(|&(s, e)| cursor.col >= s && cursor.col < e);
223 let row_conceals: Vec<&Conceal> = {
225 let mut v: Vec<&Conceal> =
226 self.conceals.iter().filter(|c| c.row == doc_row).collect();
227 v.sort_by_key(|c| c.start_byte);
228 v
229 };
230 let segments = match wrap_mode {
238 Wrap::None => vec![(top_col, usize::MAX)],
239 _ => wrap_segments(line, seg_width, wrap_mode),
240 };
241 let last_seg_idx = segments.len().saturating_sub(1);
242 for (seg_idx, &(seg_start, seg_end)) in segments.iter().enumerate() {
243 if screen_row >= area.height {
244 break;
245 }
246 if let Some(gutter) = self.gutter {
247 if seg_idx == 0 {
248 self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
249 self.paint_signs(term_buf, area, screen_row, doc_row);
250 } else {
251 self.paint_blank_gutter(term_buf, area, screen_row, gutter);
252 }
253 }
254 self.paint_row(
255 term_buf,
256 text_area,
257 screen_row,
258 line,
259 row_spans,
260 sel_range,
261 &search_ranges,
262 is_cursor_row,
263 cursor.col,
264 seg_start,
265 seg_end,
266 seg_idx == last_seg_idx,
267 &row_conceals,
268 );
269 search_hit_at_cursor_col.push(row_has_hit_at_cursor_col);
270 screen_row += 1;
271 }
272 doc_row += 1;
273 }
274 if matches!(wrap_mode, Wrap::None)
281 && self.cursor_column_bg != Style::default()
282 && cursor.col >= top_col
283 && (cursor.col - top_col) < text_area.width as usize
284 {
285 let x = text_area.x + (cursor.col - top_col) as u16;
286 for sy in 0..screen_row {
287 if search_hit_at_cursor_col
291 .get(sy as usize)
292 .copied()
293 .unwrap_or(false)
294 {
295 continue;
296 }
297 let y = text_area.y + sy;
298 if let Some(cell) = term_buf.cell_mut((x, y)) {
299 cell.set_style(cell.style().patch(self.cursor_column_bg));
300 }
301 }
302 }
303 }
304}
305
306impl<R: StyleResolver> BufferView<'_, R> {
307 fn row_search_ranges(&self, line: &str) -> Vec<(usize, usize)> {
311 let Some(re) = self.search_pattern else {
312 return Vec::new();
313 };
314 re.find_iter(line)
315 .map(|m| {
316 let start = line[..m.start()].chars().count();
317 let end = line[..m.end()].chars().count();
318 (start, end)
319 })
320 .collect()
321 }
322
323 fn paint_fold_marker(
324 &self,
325 term_buf: &mut TermBuffer,
326 area: Rect,
327 screen_row: u16,
328 fold: crate::Fold,
329 first_line: &str,
330 is_cursor_row: bool,
331 ) {
332 let y = area.y + screen_row;
333 let style = if is_cursor_row && self.cursor_line_bg != Style::default() {
334 self.cursor_line_bg
335 } else {
336 Style::default()
337 };
338 for x in area.x..(area.x + area.width) {
340 if let Some(cell) = term_buf.cell_mut((x, y)) {
341 cell.set_style(style);
342 }
343 }
344 let prefix = first_line.trim();
348 let count = fold.line_count();
349 let label = if prefix.is_empty() {
350 format!("▸ {count} lines folded")
351 } else {
352 const MAX_PREFIX: usize = 60;
353 let trimmed = if prefix.chars().count() > MAX_PREFIX {
354 let head: String = prefix.chars().take(MAX_PREFIX - 1).collect();
355 format!("{head}…")
356 } else {
357 prefix.to_string()
358 };
359 format!("▸ {trimmed} ({count} lines)")
360 };
361 let mut x = area.x;
362 let row_end_x = area.x + area.width;
363 for ch in label.chars() {
364 if x >= row_end_x {
365 break;
366 }
367 let width = ch.width().unwrap_or(1) as u16;
368 if x + width > row_end_x {
369 break;
370 }
371 if let Some(cell) = term_buf.cell_mut((x, y)) {
372 cell.set_char(ch);
373 cell.set_style(style);
374 }
375 x = x.saturating_add(width);
376 }
377 }
378
379 fn paint_signs(&self, term_buf: &mut TermBuffer, area: Rect, screen_row: u16, doc_row: usize) {
380 let Some(sign) = self
381 .signs
382 .iter()
383 .filter(|s| s.row == doc_row)
384 .max_by_key(|s| s.priority)
385 else {
386 return;
387 };
388 let y = area.y + screen_row;
389 let x = area.x;
390 if let Some(cell) = term_buf.cell_mut((x, y)) {
391 cell.set_char(sign.ch);
392 cell.set_style(sign.style);
393 }
394 }
395
396 fn paint_blank_gutter(
399 &self,
400 term_buf: &mut TermBuffer,
401 area: Rect,
402 screen_row: u16,
403 gutter: Gutter,
404 ) {
405 let y = area.y + screen_row;
406 for x in area.x..(area.x + gutter.width) {
407 if let Some(cell) = term_buf.cell_mut((x, y)) {
408 cell.set_char(' ');
409 cell.set_style(gutter.style);
410 }
411 }
412 }
413
414 fn paint_gutter(
415 &self,
416 term_buf: &mut TermBuffer,
417 area: Rect,
418 screen_row: u16,
419 doc_row: usize,
420 gutter: Gutter,
421 ) {
422 let y = area.y + screen_row;
423 let number_width = gutter.width.saturating_sub(1) as usize;
425 let label = format!(
426 "{:>width$}",
427 doc_row + 1 + gutter.line_offset,
428 width = number_width
429 );
430 let mut x = area.x;
431 for ch in label.chars() {
432 if x >= area.x + gutter.width.saturating_sub(1) {
433 break;
434 }
435 if let Some(cell) = term_buf.cell_mut((x, y)) {
436 cell.set_char(ch);
437 cell.set_style(gutter.style);
438 }
439 x = x.saturating_add(1);
440 }
441 let spacer_x = area.x + gutter.width.saturating_sub(1);
444 if let Some(cell) = term_buf.cell_mut((spacer_x, y)) {
445 cell.set_char(' ');
446 cell.set_style(gutter.style);
447 }
448 }
449
450 #[allow(clippy::too_many_arguments)]
451 fn paint_row(
452 &self,
453 term_buf: &mut TermBuffer,
454 area: Rect,
455 screen_row: u16,
456 line: &str,
457 row_spans: &[crate::Span],
458 sel_range: crate::RowSpan,
459 search_ranges: &[(usize, usize)],
460 is_cursor_row: bool,
461 cursor_col: usize,
462 seg_start: usize,
463 seg_end: usize,
464 is_last_segment: bool,
465 conceals: &[&Conceal],
466 ) {
467 let y = area.y + screen_row;
468 let mut screen_x = area.x;
469 let row_end_x = area.x + area.width;
470
471 if is_cursor_row && self.cursor_line_bg != Style::default() {
475 for x in area.x..row_end_x {
476 if let Some(cell) = term_buf.cell_mut((x, y)) {
477 cell.set_style(self.cursor_line_bg);
478 }
479 }
480 }
481
482 let tab_width = self.viewport.effective_tab_width();
486 let mut byte_offset: usize = 0;
487 let mut line_col: usize = 0;
488 let mut chars_iter = line.chars().enumerate().peekable();
489 while let Some((col_idx, ch)) = chars_iter.next() {
490 let ch_byte_len = ch.len_utf8();
491 if col_idx >= seg_end {
492 break;
493 }
494 if let Some(conc) = conceals.iter().find(|c| c.start_byte == byte_offset) {
499 if col_idx >= seg_start {
500 let mut style = if is_cursor_row {
501 self.cursor_line_bg
502 } else {
503 Style::default()
504 };
505 if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
506 style = style.patch(span_style);
507 }
508 for rch in conc.replacement.chars() {
509 let rwidth = rch.width().unwrap_or(1) as u16;
510 if screen_x + rwidth > row_end_x {
511 break;
512 }
513 if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
514 cell.set_char(rch);
515 cell.set_style(style);
516 }
517 screen_x += rwidth;
518 }
519 }
520 let mut consumed = ch_byte_len;
523 byte_offset += ch_byte_len;
524 while byte_offset < conc.end_byte {
525 let Some((_, next_ch)) = chars_iter.next() else {
526 break;
527 };
528 consumed += next_ch.len_utf8();
529 byte_offset = byte_offset.saturating_add(next_ch.len_utf8());
530 }
531 let _ = consumed;
532 continue;
533 }
534 let visible_width = if ch == '\t' {
539 tab_width - (line_col % tab_width)
540 } else {
541 ch.width().unwrap_or(1)
542 };
543 if col_idx < seg_start {
546 line_col += visible_width;
547 byte_offset += ch_byte_len;
548 continue;
549 }
550 let width = visible_width as u16;
552 if screen_x + width > row_end_x {
553 break;
554 }
555
556 let mut style = if is_cursor_row {
558 self.cursor_line_bg
559 } else {
560 Style::default()
561 };
562 if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
563 style = style.patch(span_style);
564 }
565 if self.search_bg != Style::default()
569 && search_ranges
570 .iter()
571 .any(|&(s, e)| col_idx >= s && col_idx < e)
572 {
573 style = style.patch(self.search_bg);
574 }
575 if let Some((lo, hi)) = sel_range
576 && col_idx >= lo
577 && col_idx <= hi
578 {
579 style = style.patch(self.selection_bg);
580 }
581 if is_cursor_row && col_idx == cursor_col {
582 style = style.patch(self.cursor_style);
583 }
584
585 if ch == '\t' {
586 for k in 0..width {
590 if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
591 cell.set_char(' ');
592 cell.set_style(style);
593 }
594 }
595 } else if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
596 cell.set_char(ch);
597 cell.set_style(style);
598 }
599 screen_x += width;
600 line_col += visible_width;
601 byte_offset += ch_byte_len;
602 }
603
604 if is_cursor_row
609 && is_last_segment
610 && cursor_col >= line.chars().count()
611 && cursor_col >= seg_start
612 {
613 let pad_x = area.x + (cursor_col.saturating_sub(seg_start)) as u16;
614 if pad_x < row_end_x
615 && let Some(cell) = term_buf.cell_mut((pad_x, y))
616 {
617 cell.set_char(' ');
618 cell.set_style(self.cursor_line_bg.patch(self.cursor_style));
619 }
620 }
621 }
622
623 fn resolve_span_style(&self, row_spans: &[crate::Span], byte_offset: usize) -> Option<Style> {
626 let mut best: Option<&crate::Span> = None;
631 for span in row_spans {
632 if byte_offset >= span.start_byte && byte_offset < span.end_byte {
633 let len = span.end_byte - span.start_byte;
634 match best {
635 Some(b) if (b.end_byte - b.start_byte) <= len => {}
636 _ => best = Some(span),
637 }
638 }
639 }
640 best.map(|s| self.resolver.resolve(s.style))
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use ratatui::style::{Color, Modifier};
648 use ratatui::widgets::Widget;
649
650 fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
651 let area = Rect::new(0, 0, w, h);
652 let mut buf = TermBuffer::empty(area);
653 view.render(area, &mut buf);
654 buf
655 }
656
657 fn no_styles(_id: u32) -> Style {
658 Style::default()
659 }
660
661 fn vp(width: u16, height: u16) -> Viewport {
663 Viewport {
664 top_row: 0,
665 top_col: 0,
666 width,
667 height,
668 wrap: Wrap::None,
669 text_width: width,
670 tab_width: 0,
671 }
672 }
673
674 #[test]
675 fn renders_plain_chars_into_terminal_buffer() {
676 let b = Buffer::from_str("hello\nworld");
677 let v = vp(20, 5);
678 let view = BufferView {
679 buffer: &b,
680 viewport: &v,
681 selection: None,
682 resolver: &(no_styles as fn(u32) -> Style),
683 cursor_line_bg: Style::default(),
684 cursor_column_bg: Style::default(),
685 selection_bg: Style::default().bg(Color::Blue),
686 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
687 gutter: None,
688 search_bg: Style::default(),
689 signs: &[],
690 conceals: &[],
691 spans: &[],
692 search_pattern: None,
693 };
694 let term = run_render(view, 20, 5);
695 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
696 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
697 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
698 assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
699 }
700
701 #[test]
702 fn cursor_cell_gets_reversed_style() {
703 let mut b = Buffer::from_str("abc");
704 let v = vp(10, 1);
705 b.set_cursor(crate::Position::new(0, 1));
706 let view = BufferView {
707 buffer: &b,
708 viewport: &v,
709 selection: None,
710 resolver: &(no_styles as fn(u32) -> Style),
711 cursor_line_bg: Style::default(),
712 cursor_column_bg: Style::default(),
713 selection_bg: Style::default().bg(Color::Blue),
714 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
715 gutter: None,
716 search_bg: Style::default(),
717 signs: &[],
718 conceals: &[],
719 spans: &[],
720 search_pattern: None,
721 };
722 let term = run_render(view, 10, 1);
723 let cursor_cell = term.cell((1, 0)).unwrap();
724 assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
725 }
726
727 #[test]
728 fn selection_bg_applies_only_to_selected_cells() {
729 use crate::{Position, Selection};
730 let b = Buffer::from_str("abcdef");
731 let v = vp(10, 1);
732 let view = BufferView {
733 buffer: &b,
734 viewport: &v,
735 selection: Some(Selection::Char {
736 anchor: Position::new(0, 1),
737 head: Position::new(0, 3),
738 }),
739 resolver: &(no_styles as fn(u32) -> Style),
740 cursor_line_bg: Style::default(),
741 cursor_column_bg: Style::default(),
742 selection_bg: Style::default().bg(Color::Blue),
743 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
744 gutter: None,
745 search_bg: Style::default(),
746 signs: &[],
747 conceals: &[],
748 spans: &[],
749 search_pattern: None,
750 };
751 let term = run_render(view, 10, 1);
752 assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
753 for x in 1..=3 {
754 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
755 }
756 assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
757 }
758
759 #[test]
760 fn syntax_span_fg_resolves_via_table() {
761 use crate::Span;
762 let b = Buffer::from_str("SELECT foo");
763 let v = vp(20, 1);
764 let spans = vec![vec![Span::new(0, 6, 7)]];
765 let resolver = |id: u32| -> Style {
766 if id == 7 {
767 Style::default().fg(Color::Red)
768 } else {
769 Style::default()
770 }
771 };
772 let view = BufferView {
773 buffer: &b,
774 viewport: &v,
775 selection: None,
776 resolver: &resolver,
777 cursor_line_bg: Style::default(),
778 cursor_column_bg: Style::default(),
779 selection_bg: Style::default().bg(Color::Blue),
780 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
781 gutter: None,
782 search_bg: Style::default(),
783 signs: &[],
784 conceals: &[],
785 spans: &spans,
786 search_pattern: None,
787 };
788 let term = run_render(view, 20, 1);
789 for x in 0..6 {
790 assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
791 }
792 }
793
794 #[test]
795 fn gutter_renders_right_aligned_line_numbers() {
796 let b = Buffer::from_str("a\nb\nc");
797 let v = vp(10, 3);
798 let view = BufferView {
799 buffer: &b,
800 viewport: &v,
801 selection: None,
802 resolver: &(no_styles as fn(u32) -> Style),
803 cursor_line_bg: Style::default(),
804 cursor_column_bg: Style::default(),
805 selection_bg: Style::default().bg(Color::Blue),
806 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
807 gutter: Some(Gutter {
808 width: 4,
809 style: Style::default().fg(Color::Yellow),
810 line_offset: 0,
811 }),
812 search_bg: Style::default(),
813 signs: &[],
814 conceals: &[],
815 spans: &[],
816 search_pattern: None,
817 };
818 let term = run_render(view, 10, 3);
819 assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
821 assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
822 assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
823 assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
824 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
826 }
827
828 #[test]
829 fn search_bg_paints_match_cells() {
830 use regex::Regex;
831 let b = Buffer::from_str("foo bar foo");
832 let v = vp(20, 1);
833 let pat = Regex::new("foo").unwrap();
834 let view = BufferView {
835 buffer: &b,
836 viewport: &v,
837 selection: None,
838 resolver: &(no_styles as fn(u32) -> Style),
839 cursor_line_bg: Style::default(),
840 cursor_column_bg: Style::default(),
841 selection_bg: Style::default().bg(Color::Blue),
842 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
843 gutter: None,
844 search_bg: Style::default().bg(Color::Magenta),
845 signs: &[],
846 conceals: &[],
847 spans: &[],
848 search_pattern: Some(&pat),
849 };
850 let term = run_render(view, 20, 1);
851 for x in 0..3 {
852 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
853 }
854 assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
856 for x in 8..11 {
857 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
858 }
859 }
860
861 #[test]
862 fn search_bg_survives_cursorcolumn_overlay() {
863 use regex::Regex;
864 let mut b = Buffer::from_str("foo bar foo");
868 let v = vp(20, 1);
869 let pat = Regex::new("foo").unwrap();
870 b.set_cursor(crate::Position::new(0, 1));
872 let view = BufferView {
873 buffer: &b,
874 viewport: &v,
875 selection: None,
876 resolver: &(no_styles as fn(u32) -> Style),
877 cursor_line_bg: Style::default(),
878 cursor_column_bg: Style::default().bg(Color::DarkGray),
879 selection_bg: Style::default().bg(Color::Blue),
880 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
881 gutter: None,
882 search_bg: Style::default().bg(Color::Magenta),
883 signs: &[],
884 conceals: &[],
885 spans: &[],
886 search_pattern: Some(&pat),
887 };
888 let term = run_render(view, 20, 1);
889 assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
891 }
892
893 #[test]
894 fn highest_priority_sign_wins_per_row_and_overwrites_gutter() {
895 let b = Buffer::from_str("a\nb\nc");
896 let v = vp(10, 3);
897 let signs = [
898 Sign {
899 row: 0,
900 ch: 'W',
901 style: Style::default().fg(Color::Yellow),
902 priority: 1,
903 },
904 Sign {
905 row: 0,
906 ch: 'E',
907 style: Style::default().fg(Color::Red),
908 priority: 2,
909 },
910 ];
911 let view = BufferView {
912 buffer: &b,
913 viewport: &v,
914 selection: None,
915 resolver: &(no_styles as fn(u32) -> Style),
916 cursor_line_bg: Style::default(),
917 cursor_column_bg: Style::default(),
918 selection_bg: Style::default().bg(Color::Blue),
919 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
920 gutter: Some(Gutter {
921 width: 3,
922 style: Style::default().fg(Color::DarkGray),
923 line_offset: 0,
924 }),
925 search_bg: Style::default(),
926 signs: &signs,
927 conceals: &[],
928 spans: &[],
929 search_pattern: None,
930 };
931 let term = run_render(view, 10, 3);
932 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
933 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
934 assert_ne!(term.cell((0, 1)).unwrap().symbol(), "E");
936 }
937
938 #[test]
939 fn conceal_replaces_byte_range() {
940 let b = Buffer::from_str("see https://example.com end");
941 let v = vp(30, 1);
942 let conceals = vec![Conceal {
943 row: 0,
944 start_byte: 4, end_byte: 4 + "https://example.com".len(), replacement: "🔗".to_string(),
947 }];
948 let view = BufferView {
949 buffer: &b,
950 viewport: &v,
951 selection: None,
952 resolver: &(no_styles as fn(u32) -> Style),
953 cursor_line_bg: Style::default(),
954 cursor_column_bg: Style::default(),
955 selection_bg: Style::default(),
956 cursor_style: Style::default(),
957 gutter: None,
958 search_bg: Style::default(),
959 signs: &[],
960 conceals: &conceals,
961 spans: &[],
962 search_pattern: None,
963 };
964 let term = run_render(view, 30, 1);
965 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
967 assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
968 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
971 }
972
973 #[test]
974 fn closed_fold_collapses_rows_and_paints_marker() {
975 let mut b = Buffer::from_str("a\nb\nc\nd\ne");
976 let v = vp(30, 5);
977 b.add_fold(1, 3, true);
979 let view = BufferView {
980 buffer: &b,
981 viewport: &v,
982 selection: None,
983 resolver: &(no_styles as fn(u32) -> Style),
984 cursor_line_bg: Style::default(),
985 cursor_column_bg: Style::default(),
986 selection_bg: Style::default().bg(Color::Blue),
987 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
988 gutter: None,
989 search_bg: Style::default(),
990 signs: &[],
991 conceals: &[],
992 spans: &[],
993 search_pattern: None,
994 };
995 let term = run_render(view, 30, 5);
996 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
998 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "▸");
1001 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
1003 }
1004
1005 #[test]
1006 fn open_fold_renders_normally() {
1007 let mut b = Buffer::from_str("a\nb\nc");
1008 let v = vp(5, 3);
1009 b.add_fold(0, 2, false); let view = BufferView {
1011 buffer: &b,
1012 viewport: &v,
1013 selection: None,
1014 resolver: &(no_styles as fn(u32) -> Style),
1015 cursor_line_bg: Style::default(),
1016 cursor_column_bg: Style::default(),
1017 selection_bg: Style::default().bg(Color::Blue),
1018 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1019 gutter: None,
1020 search_bg: Style::default(),
1021 signs: &[],
1022 conceals: &[],
1023 spans: &[],
1024 search_pattern: None,
1025 };
1026 let term = run_render(view, 5, 3);
1027 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1028 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1029 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
1030 }
1031
1032 #[test]
1033 fn horizontal_scroll_clips_left_chars() {
1034 let b = Buffer::from_str("abcdefgh");
1035 let mut v = vp(4, 1);
1036 v.top_col = 3;
1037 let view = BufferView {
1038 buffer: &b,
1039 viewport: &v,
1040 selection: None,
1041 resolver: &(no_styles as fn(u32) -> Style),
1042 cursor_line_bg: Style::default(),
1043 cursor_column_bg: Style::default(),
1044 selection_bg: Style::default().bg(Color::Blue),
1045 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1046 gutter: None,
1047 search_bg: Style::default(),
1048 signs: &[],
1049 conceals: &[],
1050 spans: &[],
1051 search_pattern: None,
1052 };
1053 let term = run_render(view, 4, 1);
1054 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
1055 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
1056 }
1057
1058 fn make_wrap_view<'a>(
1059 b: &'a Buffer,
1060 viewport: &'a Viewport,
1061 resolver: &'a (impl StyleResolver + 'a),
1062 gutter: Option<Gutter>,
1063 ) -> BufferView<'a, impl StyleResolver + 'a> {
1064 BufferView {
1065 buffer: b,
1066 viewport,
1067 selection: None,
1068 resolver,
1069 cursor_line_bg: Style::default(),
1070 cursor_column_bg: Style::default(),
1071 selection_bg: Style::default().bg(Color::Blue),
1072 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1073 gutter,
1074 search_bg: Style::default(),
1075 signs: &[],
1076 conceals: &[],
1077 spans: &[],
1078 search_pattern: None,
1079 }
1080 }
1081
1082 #[test]
1083 fn wrap_segments_char_breaks_at_width() {
1084 let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
1085 assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
1086 }
1087
1088 #[test]
1089 fn wrap_segments_word_backs_up_to_whitespace() {
1090 let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
1091 assert_eq!(segs[0], (0, 6));
1093 assert_eq!(segs[1], (6, 11));
1095 assert_eq!(segs[2], (11, 16));
1096 }
1097
1098 #[test]
1099 fn wrap_segments_word_falls_back_to_char_for_long_runs() {
1100 let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
1101 assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
1103 }
1104
1105 #[test]
1106 fn wrap_char_paints_continuation_rows() {
1107 let b = Buffer::from_str("abcdefghij");
1108 let v = Viewport {
1109 top_row: 0,
1110 top_col: 0,
1111 width: 4,
1112 height: 3,
1113 wrap: Wrap::Char,
1114 text_width: 4,
1115 tab_width: 0,
1116 };
1117 let r = no_styles as fn(u32) -> Style;
1118 let view = make_wrap_view(&b, &v, &r, None);
1119 let term = run_render(view, 4, 3);
1120 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1122 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
1123 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
1125 assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
1126 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
1128 assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
1129 }
1130
1131 #[test]
1132 fn wrap_char_gutter_blank_on_continuation() {
1133 let b = Buffer::from_str("abcdefgh");
1134 let v = Viewport {
1135 top_row: 0,
1136 top_col: 0,
1137 width: 6,
1138 height: 3,
1139 wrap: Wrap::Char,
1140 text_width: 3,
1142 tab_width: 0,
1143 };
1144 let r = no_styles as fn(u32) -> Style;
1145 let gutter = Gutter {
1146 width: 3,
1147 style: Style::default().fg(Color::Yellow),
1148 line_offset: 0,
1149 };
1150 let view = make_wrap_view(&b, &v, &r, Some(gutter));
1151 let term = run_render(view, 6, 3);
1152 assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
1154 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
1155 for x in 0..2 {
1157 assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
1158 }
1159 assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
1160 assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
1161 }
1162
1163 #[test]
1164 fn wrap_char_cursor_lands_on_correct_segment() {
1165 let mut b = Buffer::from_str("abcdefghij");
1166 let v = Viewport {
1167 top_row: 0,
1168 top_col: 0,
1169 width: 4,
1170 height: 3,
1171 wrap: Wrap::Char,
1172 text_width: 4,
1173 tab_width: 0,
1174 };
1175 b.set_cursor(crate::Position::new(0, 6));
1177 let r = no_styles as fn(u32) -> Style;
1178 let mut view = make_wrap_view(&b, &v, &r, None);
1179 view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1180 let term = run_render(view, 4, 3);
1181 assert!(
1182 term.cell((2, 1))
1183 .unwrap()
1184 .modifier
1185 .contains(Modifier::REVERSED)
1186 );
1187 }
1188
1189 #[test]
1190 fn wrap_char_eol_cursor_placeholder_on_last_segment() {
1191 let mut b = Buffer::from_str("abcdef");
1192 let v = Viewport {
1193 top_row: 0,
1194 top_col: 0,
1195 width: 4,
1196 height: 3,
1197 wrap: Wrap::Char,
1198 text_width: 4,
1199 tab_width: 0,
1200 };
1201 b.set_cursor(crate::Position::new(0, 6));
1203 let r = no_styles as fn(u32) -> Style;
1204 let mut view = make_wrap_view(&b, &v, &r, None);
1205 view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1206 let term = run_render(view, 4, 3);
1207 assert!(
1209 term.cell((2, 1))
1210 .unwrap()
1211 .modifier
1212 .contains(Modifier::REVERSED)
1213 );
1214 }
1215
1216 #[test]
1217 fn wrap_word_breaks_at_whitespace() {
1218 let b = Buffer::from_str("alpha beta gamma");
1219 let v = Viewport {
1220 top_row: 0,
1221 top_col: 0,
1222 width: 8,
1223 height: 3,
1224 wrap: Wrap::Word,
1225 text_width: 8,
1226 tab_width: 0,
1227 };
1228 let r = no_styles as fn(u32) -> Style;
1229 let view = make_wrap_view(&b, &v, &r, None);
1230 let term = run_render(view, 8, 3);
1231 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1233 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1234 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1236 assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
1237 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
1239 assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
1240 }
1241
1242 fn view_with<'a>(
1248 b: &'a Buffer,
1249 viewport: &'a Viewport,
1250 resolver: &'a (impl StyleResolver + 'a),
1251 spans: &'a [Vec<Span>],
1252 search_pattern: Option<&'a regex::Regex>,
1253 ) -> BufferView<'a, impl StyleResolver + 'a> {
1254 BufferView {
1255 buffer: b,
1256 viewport,
1257 selection: None,
1258 resolver,
1259 cursor_line_bg: Style::default(),
1260 cursor_column_bg: Style::default(),
1261 selection_bg: Style::default().bg(Color::Blue),
1262 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1263 gutter: None,
1264 search_bg: Style::default().bg(Color::Magenta),
1265 signs: &[],
1266 conceals: &[],
1267 spans,
1268 search_pattern,
1269 }
1270 }
1271
1272 #[test]
1273 fn empty_spans_param_renders_default_style() {
1274 let b = Buffer::from_str("hello");
1275 let v = vp(10, 1);
1276 let r = no_styles as fn(u32) -> Style;
1277 let view = view_with(&b, &v, &r, &[], None);
1278 let term = run_render(view, 10, 1);
1279 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
1280 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
1281 }
1282
1283 #[test]
1284 fn spans_param_paints_styled_byte_range() {
1285 let b = Buffer::from_str("abcdef");
1286 let v = vp(10, 1);
1287 let resolver = |id: u32| -> Style {
1288 if id == 3 {
1289 Style::default().fg(Color::Green)
1290 } else {
1291 Style::default()
1292 }
1293 };
1294 let spans = vec![vec![Span::new(0, 3, 3)]];
1295 let view = view_with(&b, &v, &resolver, &spans, None);
1296 let term = run_render(view, 10, 1);
1297 for x in 0..3 {
1298 assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
1299 }
1300 assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
1301 }
1302
1303 #[test]
1304 fn spans_param_handles_per_row_overlay() {
1305 let b = Buffer::from_str("abc\ndef");
1306 let v = vp(10, 2);
1307 let resolver = |id: u32| -> Style {
1308 if id == 1 {
1309 Style::default().fg(Color::Red)
1310 } else {
1311 Style::default().fg(Color::Green)
1312 }
1313 };
1314 let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
1315 let view = view_with(&b, &v, &resolver, &spans, None);
1316 let term = run_render(view, 10, 2);
1317 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1318 assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
1319 }
1320
1321 #[test]
1322 fn spans_param_rows_beyond_get_no_styling() {
1323 let b = Buffer::from_str("abc\ndef\nghi");
1324 let v = vp(10, 3);
1325 let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1326 let spans = vec![vec![Span::new(0, 3, 0)]];
1328 let view = view_with(&b, &v, &resolver, &spans, None);
1329 let term = run_render(view, 10, 3);
1330 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1331 assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
1332 assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
1333 }
1334
1335 #[test]
1336 fn search_pattern_none_disables_hlsearch() {
1337 let b = Buffer::from_str("foo bar foo");
1338 let v = vp(20, 1);
1339 let r = no_styles as fn(u32) -> Style;
1340 let view = view_with(&b, &v, &r, &[], None);
1342 let term = run_render(view, 20, 1);
1343 for x in 0..11 {
1344 assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1345 }
1346 }
1347
1348 #[test]
1349 fn search_pattern_regex_paints_match_bg() {
1350 use regex::Regex;
1351 let b = Buffer::from_str("xyz foo xyz");
1352 let v = vp(20, 1);
1353 let r = no_styles as fn(u32) -> Style;
1354 let pat = Regex::new("foo").unwrap();
1355 let view = view_with(&b, &v, &r, &[], Some(&pat));
1356 let term = run_render(view, 20, 1);
1357 assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1359 for x in 4..7 {
1360 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1361 }
1362 assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
1363 }
1364
1365 #[test]
1366 fn search_pattern_unicode_columns_are_charwise() {
1367 use regex::Regex;
1368 let b = Buffer::from_str("tablé foo");
1370 let v = vp(20, 1);
1371 let r = no_styles as fn(u32) -> Style;
1372 let pat = Regex::new("foo").unwrap();
1373 let view = view_with(&b, &v, &r, &[], Some(&pat));
1374 let term = run_render(view, 20, 1);
1375 assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
1377 assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
1378 assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
1379 }
1380
1381 #[test]
1382 fn spans_param_clamps_short_row_overlay() {
1383 let b = Buffer::from_str("abc");
1385 let v = vp(10, 1);
1386 let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1387 let spans = vec![vec![Span::new(0, 100, 0)]];
1388 let view = view_with(&b, &v, &resolver, &spans, None);
1389 let term = run_render(view, 10, 1);
1390 for x in 0..3 {
1391 assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1392 }
1393 }
1394
1395 #[test]
1396 fn spans_and_search_pattern_compose() {
1397 use regex::Regex;
1399 let b = Buffer::from_str("foo");
1400 let v = vp(10, 1);
1401 let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
1402 let spans = vec![vec![Span::new(0, 3, 0)]];
1403 let pat = Regex::new("foo").unwrap();
1404 let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
1405 let term = run_render(view, 10, 1);
1406 let cell = term.cell((1, 0)).unwrap();
1407 assert_eq!(cell.fg, Color::Green);
1408 assert_eq!(cell.bg, Color::Magenta);
1409 }
1410}