1use ratatui::buffer::Buffer as TermBuffer;
25use ratatui::layout::Rect;
26use ratatui::style::Style;
27use ratatui::widgets::Widget;
28use unicode_width::UnicodeWidthChar;
29
30use hjkl_buffer::wrap::wrap_segments;
31use hjkl_buffer::{Buffer, Selection, Span, Viewport, Wrap};
32
33pub trait StyleResolver {
37 fn resolve(&self, style_id: u32) -> Style;
38}
39
40impl<F: Fn(u32) -> Style> StyleResolver for F {
42 fn resolve(&self, style_id: u32) -> Style {
43 self(style_id)
44 }
45}
46
47pub struct BufferView<'a, R: StyleResolver> {
62 pub buffer: &'a Buffer,
63 pub viewport: &'a Viewport,
67 pub selection: Option<Selection>,
68 pub resolver: &'a R,
69 pub cursor_line_bg: Style,
72 pub fold_line_bg: Style,
76 pub cursor_column_bg: Style,
79 pub selection_bg: Style,
81 pub cursor_style: Style,
84 pub gutter: Option<Gutter>,
88 pub search_bg: Style,
91 pub signs: &'a [Sign],
96 pub conceals: &'a [Conceal],
100 pub spans: &'a [Vec<Span>],
109 pub search_pattern: Option<&'a regex::Regex>,
117 pub non_text_style: Style,
124 pub diag_overlays: &'a [DiagOverlay],
129 pub colorcolumn_cols: &'a [u16],
133 pub colorcolumn_style: Style,
136 pub listchars: Option<&'a hjkl_buffer::ListChars>,
140 pub indent_guides_enabled: bool,
144 pub indent_guide_char: char,
146 pub indent_guide_shiftwidth: usize,
150 pub indent_guide_fg: ratatui::style::Color,
152 pub indent_guide_active_fg: ratatui::style::Color,
154 pub indent_guide_active_col: Option<usize>,
158 pub eol_hints: &'a [EolHint],
161 pub blame_plan: Option<&'a [BlameRow]>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum BlameRow {
173 Content(usize),
175 BorderTop(String),
177 BorderBottom,
179}
180
181#[derive(Debug, Clone, Copy, Default)]
185pub enum GutterNumbers {
186 None,
188 #[default]
190 Absolute,
191 Relative { cursor_row: usize },
193 Hybrid { cursor_row: usize },
196}
197
198#[derive(Debug, Clone, Copy, Default)]
215pub struct Gutter {
216 pub width: u16,
219 pub style: Style,
220 pub line_offset: usize,
221 pub numbers: GutterNumbers,
223 pub sign_column_width: u16,
228 pub fold_column_width: u16,
235}
236
237#[derive(Debug, Clone, Copy)]
242pub struct Sign {
243 pub row: usize,
244 pub ch: char,
245 pub style: Style,
246 pub priority: u8,
247}
248
249#[derive(Debug, Clone)]
254pub struct Conceal {
255 pub row: usize,
256 pub start_byte: usize,
257 pub end_byte: usize,
258 pub replacement: String,
259}
260
261#[derive(Debug, Clone, Copy)]
267pub struct DiagOverlay {
268 pub row: usize,
270 pub col_start: usize,
272 pub col_end: usize,
274 pub style: Style,
276}
277
278#[derive(Debug, Clone)]
284pub struct EolHint {
285 pub row: usize,
287 pub text: String,
289 pub style: Style,
291}
292
293fn fold_column_glyph(folds: &[hjkl_buffer::Fold], doc_row: usize) -> char {
299 let mut inside_open = false;
300 for f in folds {
301 if f.start_row == doc_row {
302 return if f.closed { '▸' } else { '▾' };
303 }
304 if !f.closed && doc_row > f.start_row && doc_row <= f.end_row {
305 inside_open = true;
306 }
307 }
308 if inside_open { '│' } else { ' ' }
309}
310
311fn display_width(line: &str, tab_width: usize) -> usize {
316 let mut col: usize = 0;
317 for ch in line.chars() {
318 if ch == '\t' {
319 col += tab_width - (col % tab_width);
320 } else {
321 col += ch.width().unwrap_or(1);
322 }
323 }
324 col
325}
326
327impl<R: StyleResolver> Widget for BufferView<'_, R> {
328 fn render(self, area: Rect, term_buf: &mut TermBuffer) {
329 if let Some(plan) = self.blame_plan {
333 self.render_blame_plan(area, term_buf, plan);
334 return;
335 }
336 let viewport = *self.viewport;
337 let cursor = self.buffer.cursor();
338 let spans = self.spans;
339 let folds = self.buffer.folds();
340 let top_row = viewport.top_row;
341 let top_col = viewport.top_col;
342 let total_rows = self.buffer.row_count();
348 let prefetch_end = top_row.saturating_add(area.height as usize).min(total_rows);
349 let rope = self.buffer.rope();
350 let lines_prefetch: Vec<String> = (top_row..prefetch_end)
351 .map(|i| hjkl_buffer::rope_line_str(&rope, i))
352 .collect();
353 let prefetch_base = top_row;
354 let prefetch_end_idx = prefetch_end;
355 let line_at = |row: usize| -> String {
356 if row >= prefetch_base && row < prefetch_end_idx {
357 lines_prefetch[row - prefetch_base].clone()
358 } else {
359 hjkl_buffer::rope_line_str(&rope, row)
360 }
361 };
362
363 let gutter_total = self
364 .gutter
365 .map(|g| g.sign_column_width + g.width + g.fold_column_width)
366 .unwrap_or(0);
367 let text_area = Rect {
368 x: area.x.saturating_add(gutter_total),
369 y: area.y,
370 width: area.width.saturating_sub(gutter_total),
371 height: area.height,
372 };
373
374 let mut doc_row = top_row;
376 let mut screen_row: u16 = 0;
377 let wrap_mode = viewport.wrap;
378 let seg_width = if viewport.text_width > 0 {
379 viewport.text_width
380 } else {
381 text_area.width
382 };
383 let mut search_hit_at_cursor_col: Vec<bool> = Vec::new();
389 let mut screen_to_doc: Vec<Option<usize>> = vec![None; area.height as usize];
394 while doc_row < total_rows && screen_row < area.height {
398 if folds.iter().any(|f| f.hides(doc_row)) {
401 doc_row += 1;
402 continue;
403 }
404 let folded_at_start = folds
405 .iter()
406 .find(|f| f.closed && f.start_row == doc_row)
407 .copied();
408 let line_owned = line_at(doc_row);
409 let line: &str = line_owned.as_str();
410 let row_spans = spans.get(doc_row).map(Vec::as_slice).unwrap_or(&[]);
411 let sel_range = self.selection.and_then(|s| s.row_span(doc_row));
412 let is_cursor_row = doc_row == cursor.row;
413 if let Some(fold) = folded_at_start {
414 if let Some(gutter) = self.gutter {
415 self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
416 self.paint_signs(term_buf, area, screen_row, doc_row, gutter);
417 self.paint_fold_column(term_buf, area, screen_row, doc_row, gutter, &folds);
418 }
419 let fold_search_ranges = self.row_search_ranges(line);
424 let fold_conceals: Vec<&Conceal> = {
425 let mut v: Vec<&Conceal> =
426 self.conceals.iter().filter(|c| c.row == doc_row).collect();
427 v.sort_by_key(|c| c.start_byte);
428 v
429 };
430 self.paint_row(
431 term_buf,
432 text_area,
433 screen_row,
434 line,
435 row_spans,
436 sel_range,
437 &fold_search_ranges,
438 is_cursor_row,
439 cursor.col,
440 top_col,
441 usize::MAX,
442 true, &fold_conceals,
444 );
445 if self.fold_line_bg != Style::default() {
449 let y = area.y + screen_row;
450 for x in area.x..(area.x + area.width) {
451 if let Some(cell) = term_buf.cell_mut((x, y)) {
452 cell.set_style(cell.style().patch(self.fold_line_bg));
453 }
454 }
455 }
456 let fold_has_hit = fold_search_ranges
457 .iter()
458 .any(|&(s, e)| cursor.col >= s && cursor.col < e);
459 search_hit_at_cursor_col.push(fold_has_hit);
460 if (screen_row as usize) < screen_to_doc.len() {
461 screen_to_doc[screen_row as usize] = Some(doc_row);
462 }
463 screen_row += 1;
464 doc_row = fold.end_row + 1;
465 continue;
466 }
467 let search_ranges = self.row_search_ranges(line);
468 let row_has_hit_at_cursor_col = search_ranges
469 .iter()
470 .any(|&(s, e)| cursor.col >= s && cursor.col < e);
471 let row_conceals: Vec<&Conceal> = {
473 let mut v: Vec<&Conceal> =
474 self.conceals.iter().filter(|c| c.row == doc_row).collect();
475 v.sort_by_key(|c| c.start_byte);
476 v
477 };
478 let segments = match wrap_mode {
486 Wrap::None => vec![(top_col, usize::MAX)],
487 _ => wrap_segments(line, seg_width, wrap_mode),
488 };
489 let last_seg_idx = segments.len().saturating_sub(1);
490 for (seg_idx, &(seg_start, seg_end)) in segments.iter().enumerate() {
491 if screen_row >= area.height {
492 break;
493 }
494 if let Some(gutter) = self.gutter {
495 if seg_idx == 0 {
496 self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
497 self.paint_signs(term_buf, area, screen_row, doc_row, gutter);
498 self.paint_fold_column(term_buf, area, screen_row, doc_row, gutter, &folds);
499 } else {
500 self.paint_blank_gutter(term_buf, area, screen_row, gutter);
501 }
502 }
503 self.paint_row(
504 term_buf,
505 text_area,
506 screen_row,
507 line,
508 row_spans,
509 sel_range,
510 &search_ranges,
511 is_cursor_row,
512 cursor.col,
513 seg_start,
514 seg_end,
515 seg_idx == last_seg_idx,
516 &row_conceals,
517 );
518 search_hit_at_cursor_col.push(row_has_hit_at_cursor_col);
519 if seg_idx == last_seg_idx && (screen_row as usize) < screen_to_doc.len() {
520 screen_to_doc[screen_row as usize] = Some(doc_row);
521 }
522 screen_row += 1;
523 }
524 doc_row += 1;
525 }
526 while screen_row < area.height {
529 if let Some(gutter) = self.gutter {
531 self.paint_blank_gutter(term_buf, area, screen_row, gutter);
532 }
533 let y = text_area.y + screen_row;
535 if let Some(cell) = term_buf.cell_mut((text_area.x, y)) {
536 cell.set_char('~');
537 cell.set_style(self.non_text_style);
538 }
539 screen_row += 1;
540 }
541 if !self.eol_hints.is_empty() {
543 let tab_width = self.viewport.effective_tab_width();
544 for (sr, maybe_doc) in screen_to_doc.iter().enumerate() {
545 let Some(doc) = *maybe_doc else { continue };
546 let Some(hint) = self.eol_hints.iter().find(|h| h.row == doc) else {
547 continue;
548 };
549 let line = line_at(doc);
552 let text_cols = display_width(&line, tab_width);
553 let start_vis = text_cols.saturating_sub(top_col);
554 let x = text_area
556 .x
557 .saturating_add(start_vis as u16)
558 .saturating_add(2);
559 let y = text_area.y + sr as u16;
560 let right = text_area.x + text_area.width;
561 if x >= right {
562 continue; }
564 for (i, ch) in hint.text.chars().enumerate() {
565 let cx = x + i as u16;
566 if cx >= right {
567 break;
568 }
569 if let Some(cell) = term_buf.cell_mut((cx, y)) {
570 cell.set_char(ch);
571 cell.set_style(hint.style);
572 }
573 }
574 }
575 }
576
577 if matches!(wrap_mode, Wrap::None)
584 && self.cursor_column_bg != Style::default()
585 && cursor.col >= top_col
586 && (cursor.col - top_col) < text_area.width as usize
587 {
588 let x = text_area.x + (cursor.col - top_col) as u16;
589 for sy in 0..screen_row {
590 if search_hit_at_cursor_col
594 .get(sy as usize)
595 .copied()
596 .unwrap_or(false)
597 {
598 continue;
599 }
600 let y = text_area.y + sy;
601 if let Some(cell) = term_buf.cell_mut((x, y)) {
602 cell.set_style(cell.style().patch(self.cursor_column_bg));
603 }
604 }
605 }
606
607 if matches!(wrap_mode, Wrap::None) && !self.colorcolumn_cols.is_empty() {
611 for &col_1based in self.colorcolumn_cols {
612 let col = col_1based as usize; if col == 0 || col < top_col + 1 {
614 continue; }
616 let screen_col = col - 1 - top_col; if screen_col >= text_area.width as usize {
618 continue; }
620 let x = text_area.x + screen_col as u16;
621 for sy in 0..screen_row {
622 let y = text_area.y + sy;
623 if let Some(cell) = term_buf.cell_mut((x, y)) {
624 cell.set_style(cell.style().patch(self.colorcolumn_style));
625 }
626 }
627 }
628 }
629
630 if matches!(wrap_mode, Wrap::None)
635 && self.indent_guides_enabled
636 && self.indent_guide_shiftwidth > 0
637 {
638 let sw = self.indent_guide_shiftwidth;
639 let tab_width = self.viewport.effective_tab_width();
640 let mut ig_doc_row = top_row;
642 let mut ig_screen_row: u16 = 0;
643 while ig_doc_row < total_rows && ig_screen_row < area.height {
644 if folds.iter().any(|f| f.hides(ig_doc_row)) {
645 ig_doc_row += 1;
646 continue;
647 }
648 if let Some(fold) = folds
650 .iter()
651 .find(|f| f.closed && f.start_row == ig_doc_row)
652 .copied()
653 {
654 ig_screen_row += 1;
655 ig_doc_row = fold.end_row + 1;
656 continue;
657 }
658 let line_owned = line_at(ig_doc_row);
659 let line: &str = line_owned.as_str();
660 let mut leading_vcols: usize = 0;
662 for ch in line.chars() {
663 match ch {
664 ' ' => leading_vcols += 1,
665 '\t' => {
666 leading_vcols += tab_width - (leading_vcols % tab_width);
667 }
668 _ => break,
669 }
670 }
671 let y = text_area.y + ig_screen_row;
673 let mut guide_col = sw;
674 while guide_col < leading_vcols {
675 if guide_col >= top_col {
677 let screen_col = guide_col - top_col;
678 if screen_col < text_area.width as usize {
679 let x = text_area.x + screen_col as u16;
680 let is_active = Some(guide_col) == self.indent_guide_active_col;
681 let fg = if is_active {
682 self.indent_guide_active_fg
683 } else {
684 self.indent_guide_fg
685 };
686 if let Some(cell) = term_buf.cell_mut((x, y)) {
687 if cell.symbol() == " " {
690 cell.set_char(self.indent_guide_char);
691 let existing = cell.style();
693 cell.set_style(existing.fg(fg));
694 }
695 }
696 }
697 }
698 guide_col += sw;
699 }
700 ig_screen_row += 1;
701 ig_doc_row += 1;
702 }
703 }
704
705 if matches!(wrap_mode, Wrap::None) && !self.diag_overlays.is_empty() {
710 let vp_top = top_row;
714 let vp_bot = vp_top + area.height as usize;
715 for overlay in self.diag_overlays {
716 if overlay.row < vp_top || overlay.row >= vp_bot {
717 continue;
718 }
719 let mut sr: u16 = 0;
722 let mut dr = vp_top;
723 while dr < overlay.row && sr < area.height {
724 if !folds.iter().any(|f| f.hides(dr)) {
725 sr += 1;
726 }
727 dr += 1;
728 }
729 if sr >= area.height {
730 continue;
731 }
732 let y = text_area.y + sr;
733 let col_start = overlay.col_start;
736 let col_end = overlay.col_end.max(col_start + 1);
737 for col in col_start..col_end {
738 if col < top_col {
739 continue;
740 }
741 let screen_col = col - top_col;
742 if screen_col >= text_area.width as usize {
743 break;
744 }
745 let x = text_area.x + screen_col as u16;
746 if let Some(cell) = term_buf.cell_mut((x, y)) {
747 cell.set_style(cell.style().patch(overlay.style));
748 }
749 }
750 }
751 }
752 }
753}
754
755pub const BLAME_BOX_FRAME_LEFT: u16 = 1;
758
759impl<R: StyleResolver> BufferView<'_, R> {
760 fn render_blame_plan(&self, area: Rect, term_buf: &mut TermBuffer, plan: &[BlameRow]) {
765 let viewport = *self.viewport;
766 let top_col = viewport.top_col;
767 let cursor = self.buffer.cursor();
768 let folds = self.buffer.folds();
769 let rope = self.buffer.rope();
770
771 let frame_x = area.x;
772 let right_x = area.x + area.width.saturating_sub(1);
773 let inner = Rect {
775 x: area.x + BLAME_BOX_FRAME_LEFT,
776 y: area.y,
777 width: area.width.saturating_sub(BLAME_BOX_FRAME_LEFT + 1), height: area.height,
779 };
780 let gutter_total = self
781 .gutter
782 .map(|g| g.sign_column_width + g.width + g.fold_column_width)
783 .unwrap_or(0);
784 let text_area = Rect {
785 x: inner.x.saturating_add(gutter_total),
786 y: inner.y,
787 width: inner.width.saturating_sub(gutter_total),
788 height: inner.height,
789 };
790
791 let border_style = self.non_text_style;
792 let title_style = self
793 .non_text_style
794 .add_modifier(ratatui::style::Modifier::BOLD);
795
796 for (sr, item) in plan.iter().enumerate().take(area.height as usize) {
797 let screen_row = sr as u16;
798 let y = area.y + screen_row;
799 match item {
800 BlameRow::Content(dr) => {
801 let dr = *dr;
802 let line_owned = hjkl_buffer::rope_line_str(&rope, dr);
803 let line: &str = line_owned.as_str();
804 let row_spans = self.spans.get(dr).map(Vec::as_slice).unwrap_or(&[]);
805 let sel_range = self.selection.and_then(|s| s.row_span(dr));
806 let is_cursor_row = dr == cursor.row;
807 if let Some(gutter) = self.gutter {
808 self.paint_gutter(term_buf, inner, screen_row, dr, gutter);
809 self.paint_signs(term_buf, inner, screen_row, dr, gutter);
810 self.paint_fold_column(term_buf, inner, screen_row, dr, gutter, &folds);
811 }
812 let search_ranges = self.row_search_ranges(line);
813 let row_conceals: Vec<&Conceal> = {
814 let mut v: Vec<&Conceal> =
815 self.conceals.iter().filter(|c| c.row == dr).collect();
816 v.sort_by_key(|c| c.start_byte);
817 v
818 };
819 self.paint_row(
820 term_buf,
821 text_area,
822 screen_row,
823 line,
824 row_spans,
825 sel_range,
826 &search_ranges,
827 is_cursor_row,
828 cursor.col,
829 top_col,
830 usize::MAX,
831 true,
832 &row_conceals,
833 );
834 if let Some(cell) = term_buf.cell_mut((frame_x, y)) {
836 cell.set_char('\u{2502}'); cell.set_style(border_style);
838 }
839 if let Some(cell) = term_buf.cell_mut((right_x, y)) {
840 cell.set_char('\u{2502}');
841 cell.set_style(border_style);
842 }
843 }
844 BlameRow::BorderTop(_) | BlameRow::BorderBottom => {
845 let (lc, rc) = match item {
846 BlameRow::BorderTop(_) => ('\u{250c}', '\u{2510}'), _ => ('\u{2514}', '\u{2518}'), };
849 for x in frame_x..=right_x {
850 if let Some(cell) = term_buf.cell_mut((x, y)) {
851 let ch = if x == frame_x {
852 lc
853 } else if x == right_x {
854 rc
855 } else {
856 '\u{2500}' };
858 cell.set_char(ch);
859 cell.set_style(border_style);
860 }
861 }
862 if let BlameRow::BorderTop(title) = item {
863 let label = format!(" {title} ");
865 let start = frame_x + 2;
866 for (i, ch) in label.chars().enumerate() {
867 let x = start + i as u16;
868 if x >= right_x {
869 break;
870 }
871 if let Some(cell) = term_buf.cell_mut((x, y)) {
872 cell.set_char(ch);
873 cell.set_style(title_style);
874 }
875 }
876 }
877 }
878 }
879 }
880 }
881
882 fn row_search_ranges(&self, line: &str) -> Vec<(usize, usize)> {
886 let Some(re) = self.search_pattern else {
887 return Vec::new();
888 };
889 re.find_iter(line)
890 .map(|m| {
891 let start = line[..m.start()].chars().count();
892 let end = line[..m.end()].chars().count();
893 (start, end)
894 })
895 .collect()
896 }
897
898 fn paint_signs(
899 &self,
900 term_buf: &mut TermBuffer,
901 area: Rect,
902 screen_row: u16,
903 doc_row: usize,
904 gutter: Gutter,
905 ) {
906 if gutter.sign_column_width == 0 {
908 return;
909 }
910 let y = area.y + screen_row;
911 let sign_x = area.x;
912 for x in sign_x..sign_x + gutter.sign_column_width {
914 if let Some(cell) = term_buf.cell_mut((x, y)) {
915 cell.set_char(' ');
916 cell.set_style(gutter.style);
917 }
918 }
919 let Some(sign) = self
921 .signs
922 .iter()
923 .filter(|s| s.row == doc_row)
924 .max_by_key(|s| s.priority)
925 else {
926 return;
927 };
928 if let Some(cell) = term_buf.cell_mut((sign_x, y)) {
929 cell.set_char(sign.ch);
930 cell.set_style(sign.style);
931 }
932 }
933
934 fn paint_blank_gutter(
937 &self,
938 term_buf: &mut TermBuffer,
939 area: Rect,
940 screen_row: u16,
941 gutter: Gutter,
942 ) {
943 let y = area.y + screen_row;
944 let total = gutter.sign_column_width + gutter.width;
945 for x in area.x..(area.x + total) {
946 if let Some(cell) = term_buf.cell_mut((x, y)) {
947 cell.set_char(' ');
948 cell.set_style(gutter.style);
949 }
950 }
951 }
952
953 fn paint_gutter(
954 &self,
955 term_buf: &mut TermBuffer,
956 area: Rect,
957 screen_row: u16,
958 doc_row: usize,
959 gutter: Gutter,
960 ) {
961 let y = area.y + screen_row;
962 let num_start = area.x + gutter.sign_column_width;
964 let number_width = gutter.width.saturating_sub(1) as usize;
966
967 let label = match gutter.numbers {
969 GutterNumbers::None => {
970 for x in num_start..(num_start + gutter.width) {
972 if let Some(cell) = term_buf.cell_mut((x, y)) {
973 cell.set_char(' ');
974 cell.set_style(gutter.style);
975 }
976 }
977 return;
978 }
979 GutterNumbers::Absolute => {
980 format!(
981 "{:>width$}",
982 doc_row + 1 + gutter.line_offset,
983 width = number_width
984 )
985 }
986 GutterNumbers::Relative { cursor_row } => {
987 let n = if doc_row == cursor_row {
988 0
989 } else {
990 doc_row.abs_diff(cursor_row)
991 };
992 format!("{:>width$}", n, width = number_width)
993 }
994 GutterNumbers::Hybrid { cursor_row } => {
995 let n = if doc_row == cursor_row {
996 doc_row + 1 + gutter.line_offset
997 } else {
998 doc_row.abs_diff(cursor_row)
999 };
1000 format!("{:>width$}", n, width = number_width)
1001 }
1002 };
1003
1004 let mut x = num_start;
1005 for ch in label.chars() {
1006 if x >= num_start + gutter.width.saturating_sub(1) {
1007 break;
1008 }
1009 if let Some(cell) = term_buf.cell_mut((x, y)) {
1010 cell.set_char(ch);
1011 cell.set_style(gutter.style);
1012 }
1013 x = x.saturating_add(1);
1014 }
1015 let spacer_x = num_start + gutter.width.saturating_sub(1);
1018 if let Some(cell) = term_buf.cell_mut((spacer_x, y)) {
1019 cell.set_char(' ');
1020 cell.set_style(gutter.style);
1021 }
1022 }
1023
1024 fn paint_fold_column(
1031 &self,
1032 term_buf: &mut TermBuffer,
1033 area: Rect,
1034 screen_row: u16,
1035 doc_row: usize,
1036 gutter: Gutter,
1037 folds: &[hjkl_buffer::Fold],
1038 ) {
1039 if gutter.fold_column_width == 0 {
1040 return;
1041 }
1042 let y = area.y + screen_row;
1043 let fold_x = area.x + gutter.sign_column_width + gutter.width;
1044 let glyph = fold_column_glyph(folds, doc_row);
1045 for (i, x) in (fold_x..fold_x + gutter.fold_column_width).enumerate() {
1048 if let Some(cell) = term_buf.cell_mut((x, y)) {
1049 cell.set_char(if i == 0 { glyph } else { ' ' });
1050 cell.set_style(gutter.style);
1051 }
1052 }
1053 }
1054
1055 #[allow(clippy::too_many_arguments)]
1056 fn paint_row(
1057 &self,
1058 term_buf: &mut TermBuffer,
1059 area: Rect,
1060 screen_row: u16,
1061 line: &str,
1062 row_spans: &[hjkl_buffer::Span],
1063 sel_range: hjkl_buffer::RowSpan,
1064 search_ranges: &[(usize, usize)],
1065 is_cursor_row: bool,
1066 cursor_col: usize,
1067 seg_start: usize,
1068 seg_end: usize,
1069 is_last_segment: bool,
1070 conceals: &[&Conceal],
1071 ) {
1072 let y = area.y + screen_row;
1073 let mut screen_x = area.x;
1074 let row_end_x = area.x + area.width;
1075
1076 if is_cursor_row && self.cursor_line_bg != Style::default() {
1080 for x in area.x..row_end_x {
1081 if let Some(cell) = term_buf.cell_mut((x, y)) {
1082 cell.set_style(self.cursor_line_bg);
1083 }
1084 }
1085 }
1086
1087 let tab_width = self.viewport.effective_tab_width();
1091
1092 let trail_byte_start: usize = if self.listchars.is_some() {
1096 line.trim_end_matches([' ', '\t']).len()
1097 } else {
1098 line.len()
1099 };
1100
1101 let mut byte_offset: usize = 0;
1102 let mut line_col: usize = 0;
1103 let mut chars_iter = line.chars().enumerate().peekable();
1104 while let Some((col_idx, ch)) = chars_iter.next() {
1105 let ch_byte_len = ch.len_utf8();
1106 if col_idx >= seg_end {
1107 break;
1108 }
1109 if let Some(conc) = conceals.iter().find(|c| c.start_byte == byte_offset) {
1114 if col_idx >= seg_start {
1115 let mut style = if is_cursor_row {
1116 self.cursor_line_bg
1117 } else {
1118 Style::default()
1119 };
1120 if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
1121 style = style.patch(span_style);
1122 }
1123 for rch in conc.replacement.chars() {
1124 let rwidth = rch.width().unwrap_or(1) as u16;
1125 if screen_x + rwidth > row_end_x {
1126 break;
1127 }
1128 if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1129 cell.set_char(rch);
1130 cell.set_style(style);
1131 }
1132 screen_x += rwidth;
1133 }
1134 }
1135 let mut consumed = ch_byte_len;
1138 byte_offset += ch_byte_len;
1139 while byte_offset < conc.end_byte {
1140 let Some((_, next_ch)) = chars_iter.next() else {
1141 break;
1142 };
1143 consumed += next_ch.len_utf8();
1144 byte_offset = byte_offset.saturating_add(next_ch.len_utf8());
1145 }
1146 let _ = consumed;
1147 continue;
1148 }
1149 let visible_width = if ch == '\t' {
1154 tab_width - (line_col % tab_width)
1155 } else {
1156 ch.width().unwrap_or(1)
1157 };
1158 if col_idx < seg_start {
1161 line_col += visible_width;
1162 byte_offset += ch_byte_len;
1163 continue;
1164 }
1165 let width = visible_width as u16;
1167 if screen_x + width > row_end_x {
1168 break;
1169 }
1170
1171 let mut style = if is_cursor_row {
1173 self.cursor_line_bg
1174 } else {
1175 Style::default()
1176 };
1177 if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
1178 style = style.patch(span_style);
1179 }
1180 if self.search_bg != Style::default()
1184 && search_ranges
1185 .iter()
1186 .any(|&(s, e)| col_idx >= s && col_idx < e)
1187 {
1188 style = style.patch(self.search_bg);
1189 }
1190 if let Some((lo, hi)) = sel_range
1191 && col_idx >= lo
1192 && col_idx <= hi
1193 {
1194 style = style.patch(self.selection_bg);
1195 }
1196 if is_cursor_row && col_idx == cursor_col {
1197 style = style.patch(self.cursor_style);
1198 }
1199
1200 if ch == '\t' {
1201 if let Some(lc) = self.listchars {
1205 if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1207 cell.set_char(lc.tab_lead);
1208 cell.set_style(style);
1209 }
1210 let fill_ch = lc.tab_fill.unwrap_or(' ');
1211 for k in 1..width {
1212 if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
1213 cell.set_char(fill_ch);
1214 cell.set_style(style);
1215 }
1216 }
1217 } else {
1218 for k in 0..width {
1222 if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
1223 cell.set_char(' ');
1224 cell.set_style(style);
1225 }
1226 }
1227 }
1228 } else if let Some(lc) = self.listchars {
1229 let display_ch = if ch == '\u{00a0}' {
1231 lc.nbsp.unwrap_or(ch)
1233 } else if ch == ' ' {
1234 let is_trailing = byte_offset >= trail_byte_start;
1235 if is_trailing {
1236 lc.trail.or(lc.space).unwrap_or(ch)
1237 } else {
1238 lc.space.unwrap_or(ch)
1239 }
1240 } else {
1241 ch
1242 };
1243 if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1244 cell.set_char(display_ch);
1245 cell.set_style(style);
1246 }
1247 } else if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1248 cell.set_char(ch);
1249 cell.set_style(style);
1250 }
1251 screen_x += width;
1252 line_col += visible_width;
1253 byte_offset += ch_byte_len;
1254 }
1255
1256 if is_last_segment
1259 && let Some(lc) = self.listchars
1260 && let Some(eol_ch) = lc.eol
1261 && screen_x < row_end_x
1262 && let Some(cell) = term_buf.cell_mut((screen_x, y))
1263 {
1264 cell.set_char(eol_ch);
1265 cell.set_style(Style::default());
1266 screen_x += 1;
1267 }
1268 let _ = screen_x; if let Some((lo, hi)) = sel_range
1286 && is_last_segment
1287 && line.chars().count() <= seg_start
1288 {
1289 let (start_col, end_col) = if hi == usize::MAX { (0, 0) } else { (lo, hi) };
1290 for col in start_col..=end_col {
1291 let pad_x = area.x + col as u16;
1292 if pad_x >= row_end_x {
1293 break;
1294 }
1295 if let Some(cell) = term_buf.cell_mut((pad_x, y)) {
1296 let prev = cell.style();
1297 cell.set_char(' ');
1298 cell.set_style(prev.patch(self.selection_bg));
1299 }
1300 }
1301 }
1302
1303 if is_cursor_row
1308 && is_last_segment
1309 && cursor_col >= line.chars().count()
1310 && cursor_col >= seg_start
1311 {
1312 let pad_x = area.x + (cursor_col.saturating_sub(seg_start)) as u16;
1313 if pad_x < row_end_x
1314 && let Some(cell) = term_buf.cell_mut((pad_x, y))
1315 {
1316 cell.set_char(' ');
1317 cell.set_style(self.cursor_line_bg.patch(self.cursor_style));
1318 }
1319 }
1320 }
1321
1322 fn resolve_span_style(
1339 &self,
1340 row_spans: &[hjkl_buffer::Span],
1341 byte_offset: usize,
1342 ) -> Option<Style> {
1343 let mut overlapping: Vec<&hjkl_buffer::Span> = row_spans
1345 .iter()
1346 .filter(|s| byte_offset >= s.start_byte && byte_offset < s.end_byte)
1347 .collect();
1348 if overlapping.is_empty() {
1349 return None;
1350 }
1351 overlapping.sort_by_key(|s| std::cmp::Reverse(s.end_byte.saturating_sub(s.start_byte)));
1352 let mut style = self.resolver.resolve(overlapping[0].style);
1353 for s in &overlapping[1..] {
1354 style = style.patch(self.resolver.resolve(s.style));
1355 }
1356 Some(style)
1357 }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 use super::*;
1363 use ratatui::style::{Color, Modifier};
1364 use ratatui::widgets::Widget;
1365
1366 fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
1367 let area = Rect::new(0, 0, w, h);
1368 let mut buf = TermBuffer::empty(area);
1369 view.render(area, &mut buf);
1370 buf
1371 }
1372
1373 fn no_styles(_id: u32) -> Style {
1374 Style::default()
1375 }
1376
1377 fn vp(width: u16, height: u16) -> Viewport {
1379 Viewport {
1380 top_row: 0,
1381 top_col: 0,
1382 width,
1383 height,
1384 wrap: Wrap::None,
1385 text_width: width,
1386 tab_width: 0,
1387 }
1388 }
1389
1390 #[test]
1391 fn renders_plain_chars_into_terminal_buffer() {
1392 let b = Buffer::from_str("hello\nworld");
1393 let v = vp(20, 5);
1394 let view = BufferView {
1395 buffer: &b,
1396 viewport: &v,
1397 selection: None,
1398 resolver: &(no_styles as fn(u32) -> Style),
1399 cursor_line_bg: Style::default(),
1400 cursor_column_bg: Style::default(),
1401 selection_bg: Style::default().bg(Color::Blue),
1402 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1403 gutter: None,
1404 search_bg: Style::default(),
1405 signs: &[],
1406 conceals: &[],
1407 spans: &[],
1408 search_pattern: None,
1409 non_text_style: Style::default(),
1410 diag_overlays: &[],
1411 colorcolumn_cols: &[],
1412 colorcolumn_style: Style::default(),
1413 listchars: None,
1414 indent_guides_enabled: false,
1415 indent_guide_char: '│',
1416 indent_guide_shiftwidth: 4,
1417 indent_guide_fg: Color::Reset,
1418 indent_guide_active_fg: Color::Reset,
1419 indent_guide_active_col: None,
1420 fold_line_bg: Style::default(),
1421 eol_hints: &[],
1422 blame_plan: None,
1423 };
1424 let term = run_render(view, 20, 5);
1425 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
1426 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
1427 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
1428 assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
1429 }
1430
1431 #[test]
1432 fn cursor_cell_gets_reversed_style() {
1433 let mut b = Buffer::from_str("abc");
1434 let v = vp(10, 1);
1435 b.set_cursor(hjkl_buffer::Position::new(0, 1));
1436 let view = BufferView {
1437 buffer: &b,
1438 viewport: &v,
1439 selection: None,
1440 resolver: &(no_styles as fn(u32) -> Style),
1441 cursor_line_bg: Style::default(),
1442 cursor_column_bg: Style::default(),
1443 selection_bg: Style::default().bg(Color::Blue),
1444 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1445 gutter: None,
1446 search_bg: Style::default(),
1447 signs: &[],
1448 conceals: &[],
1449 spans: &[],
1450 search_pattern: None,
1451 non_text_style: Style::default(),
1452 diag_overlays: &[],
1453 colorcolumn_cols: &[],
1454 colorcolumn_style: Style::default(),
1455 listchars: None,
1456 indent_guides_enabled: false,
1457 indent_guide_char: '│',
1458 indent_guide_shiftwidth: 4,
1459 indent_guide_fg: Color::Reset,
1460 indent_guide_active_fg: Color::Reset,
1461 indent_guide_active_col: None,
1462 fold_line_bg: Style::default(),
1463 eol_hints: &[],
1464 blame_plan: None,
1465 };
1466 let term = run_render(view, 10, 1);
1467 let cursor_cell = term.cell((1, 0)).unwrap();
1468 assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
1469 }
1470
1471 #[test]
1472 fn selection_bg_applies_only_to_selected_cells() {
1473 use hjkl_buffer::{Position, Selection};
1474 let b = Buffer::from_str("abcdef");
1475 let v = vp(10, 1);
1476 let view = BufferView {
1477 buffer: &b,
1478 viewport: &v,
1479 selection: Some(Selection::Char {
1480 anchor: Position::new(0, 1),
1481 head: Position::new(0, 3),
1482 }),
1483 resolver: &(no_styles as fn(u32) -> Style),
1484 cursor_line_bg: Style::default(),
1485 cursor_column_bg: Style::default(),
1486 selection_bg: Style::default().bg(Color::Blue),
1487 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1488 gutter: None,
1489 search_bg: Style::default(),
1490 signs: &[],
1491 conceals: &[],
1492 spans: &[],
1493 search_pattern: None,
1494 non_text_style: Style::default(),
1495 diag_overlays: &[],
1496 colorcolumn_cols: &[],
1497 colorcolumn_style: Style::default(),
1498 listchars: None,
1499 indent_guides_enabled: false,
1500 indent_guide_char: '│',
1501 indent_guide_shiftwidth: 4,
1502 indent_guide_fg: Color::Reset,
1503 indent_guide_active_fg: Color::Reset,
1504 indent_guide_active_col: None,
1505 fold_line_bg: Style::default(),
1506 eol_hints: &[],
1507 blame_plan: None,
1508 };
1509 let term = run_render(view, 10, 1);
1510 assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
1511 for x in 1..=3 {
1512 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
1513 }
1514 assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
1515 }
1516
1517 #[test]
1518 fn selection_paints_placeholder_on_empty_line_charwise() {
1519 use hjkl_buffer::{Position, Selection};
1522 let b = Buffer::from_str("abc\n\nxyz");
1523 let v = vp(10, 3);
1524 let view = BufferView {
1525 buffer: &b,
1526 viewport: &v,
1527 selection: Some(Selection::Char {
1528 anchor: Position::new(0, 0),
1529 head: Position::new(2, 2),
1530 }),
1531 resolver: &(no_styles as fn(u32) -> Style),
1532 cursor_line_bg: Style::default(),
1533 cursor_column_bg: Style::default(),
1534 selection_bg: Style::default().bg(Color::Blue),
1535 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1536 gutter: None,
1537 search_bg: Style::default(),
1538 signs: &[],
1539 conceals: &[],
1540 spans: &[],
1541 search_pattern: None,
1542 non_text_style: Style::default(),
1543 diag_overlays: &[],
1544 colorcolumn_cols: &[],
1545 colorcolumn_style: Style::default(),
1546 listchars: None,
1547 indent_guides_enabled: false,
1548 indent_guide_char: '│',
1549 indent_guide_shiftwidth: 4,
1550 indent_guide_fg: Color::Reset,
1551 indent_guide_active_fg: Color::Reset,
1552 indent_guide_active_col: None,
1553 fold_line_bg: Style::default(),
1554 eol_hints: &[],
1555 blame_plan: None,
1556 };
1557 let term = run_render(view, 10, 3);
1558 assert_eq!(term.cell((0, 1)).unwrap().bg, Color::Blue);
1560 }
1561
1562 #[test]
1563 fn selection_paints_placeholder_on_empty_line_linewise() {
1564 use hjkl_buffer::Selection;
1565 let b = Buffer::from_str("abc\n\nxyz");
1566 let v = vp(10, 3);
1567 let view = BufferView {
1568 buffer: &b,
1569 viewport: &v,
1570 selection: Some(Selection::Line {
1571 anchor_row: 0,
1572 head_row: 2,
1573 }),
1574 resolver: &(no_styles as fn(u32) -> Style),
1575 cursor_line_bg: Style::default(),
1576 cursor_column_bg: Style::default(),
1577 selection_bg: Style::default().bg(Color::Blue),
1578 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1579 gutter: None,
1580 search_bg: Style::default(),
1581 signs: &[],
1582 conceals: &[],
1583 spans: &[],
1584 search_pattern: None,
1585 non_text_style: Style::default(),
1586 diag_overlays: &[],
1587 colorcolumn_cols: &[],
1588 colorcolumn_style: Style::default(),
1589 listchars: None,
1590 indent_guides_enabled: false,
1591 indent_guide_char: '│',
1592 indent_guide_shiftwidth: 4,
1593 indent_guide_fg: Color::Reset,
1594 indent_guide_active_fg: Color::Reset,
1595 indent_guide_active_col: None,
1596 fold_line_bg: Style::default(),
1597 eol_hints: &[],
1598 blame_plan: None,
1599 };
1600 let term = run_render(view, 10, 3);
1601 assert_eq!(term.cell((0, 1)).unwrap().bg, Color::Blue);
1602 }
1603
1604 #[test]
1605 fn selection_paints_placeholder_on_empty_line_blockwise() {
1606 use hjkl_buffer::{Position, Selection};
1611 let b = Buffer::from_str("abcdef\n\nuvwxyz");
1612 let v = vp(10, 3);
1613 let view = BufferView {
1614 buffer: &b,
1615 viewport: &v,
1616 selection: Some(Selection::Block {
1617 anchor: Position::new(0, 2),
1618 head: Position::new(2, 5),
1619 }),
1620 resolver: &(no_styles as fn(u32) -> Style),
1621 cursor_line_bg: Style::default(),
1622 cursor_column_bg: Style::default(),
1623 selection_bg: Style::default().bg(Color::Blue),
1624 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1625 gutter: None,
1626 search_bg: Style::default(),
1627 signs: &[],
1628 conceals: &[],
1629 spans: &[],
1630 search_pattern: None,
1631 non_text_style: Style::default(),
1632 diag_overlays: &[],
1633 colorcolumn_cols: &[],
1634 colorcolumn_style: Style::default(),
1635 listchars: None,
1636 indent_guides_enabled: false,
1637 indent_guide_char: '│',
1638 indent_guide_shiftwidth: 4,
1639 indent_guide_fg: Color::Reset,
1640 indent_guide_active_fg: Color::Reset,
1641 indent_guide_active_col: None,
1642 fold_line_bg: Style::default(),
1643 eol_hints: &[],
1644 blame_plan: None,
1645 };
1646 let term = run_render(view, 10, 3);
1647 for x in 2u16..=5 {
1649 assert_eq!(
1650 term.cell((x, 1)).unwrap().bg,
1651 Color::Blue,
1652 "empty row col {x} should carry block selection bg"
1653 );
1654 }
1655 assert!(term.cell((0, 1)).unwrap().bg != Color::Blue);
1658 assert!(term.cell((1, 1)).unwrap().bg != Color::Blue);
1659 assert!(term.cell((6, 1)).unwrap().bg != Color::Blue);
1661 for x in 2u16..=5 {
1663 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
1664 }
1665 }
1666
1667 #[test]
1668 fn selection_block_placeholder_clips_to_row_width() {
1669 use hjkl_buffer::{Position, Selection};
1671 let b = Buffer::from_str("abc\n\nxyz");
1672 let v = vp(5, 3);
1673 let view = BufferView {
1674 buffer: &b,
1675 viewport: &v,
1676 selection: Some(Selection::Block {
1677 anchor: Position::new(0, 1),
1678 head: Position::new(2, 20),
1679 }),
1680 resolver: &(no_styles as fn(u32) -> Style),
1681 cursor_line_bg: Style::default(),
1682 cursor_column_bg: Style::default(),
1683 selection_bg: Style::default().bg(Color::Blue),
1684 cursor_style: Style::default(),
1685 gutter: None,
1686 search_bg: Style::default(),
1687 signs: &[],
1688 conceals: &[],
1689 spans: &[],
1690 search_pattern: None,
1691 non_text_style: Style::default(),
1692 diag_overlays: &[],
1693 colorcolumn_cols: &[],
1694 colorcolumn_style: Style::default(),
1695 listchars: None,
1696 indent_guides_enabled: false,
1697 indent_guide_char: '│',
1698 indent_guide_shiftwidth: 4,
1699 indent_guide_fg: Color::Reset,
1700 indent_guide_active_fg: Color::Reset,
1701 indent_guide_active_col: None,
1702 fold_line_bg: Style::default(),
1703 eol_hints: &[],
1704 blame_plan: None,
1705 };
1706 let term = run_render(view, 5, 3);
1708 for x in 1u16..=4 {
1709 assert_eq!(
1710 term.cell((x, 1)).unwrap().bg,
1711 Color::Blue,
1712 "col {x} clipped block placeholder"
1713 );
1714 }
1715 }
1717
1718 #[test]
1719 fn layered_spans_blend_broad_bg_with_narrow_fg() {
1720 use hjkl_buffer::Span;
1726 let b = Buffer::from_str("fn main() {}");
1727 let v = vp(20, 1);
1728 let spans = vec![vec![
1730 Span::new(0, 12, 1), Span::new(0, 2, 2), ]];
1733 let resolver = |id: u32| -> Style {
1734 match id {
1735 1 => Style::default().bg(Color::DarkGray),
1736 2 => Style::default().fg(Color::Magenta),
1737 _ => Style::default(),
1738 }
1739 };
1740 let view = BufferView {
1741 buffer: &b,
1742 viewport: &v,
1743 selection: None,
1744 resolver: &resolver,
1745 cursor_line_bg: Style::default(),
1746 cursor_column_bg: Style::default(),
1747 selection_bg: Style::default().bg(Color::Blue),
1748 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1749 gutter: None,
1750 search_bg: Style::default(),
1751 signs: &[],
1752 conceals: &[],
1753 spans: &spans,
1754 search_pattern: None,
1755 non_text_style: Style::default(),
1756 diag_overlays: &[],
1757 colorcolumn_cols: &[],
1758 colorcolumn_style: Style::default(),
1759 listchars: None,
1760 indent_guides_enabled: false,
1761 indent_guide_char: '│',
1762 indent_guide_shiftwidth: 4,
1763 indent_guide_fg: Color::Reset,
1764 indent_guide_active_fg: Color::Reset,
1765 indent_guide_active_col: None,
1766 fold_line_bg: Style::default(),
1767 eol_hints: &[],
1768 blame_plan: None,
1769 };
1770 let term = run_render(view, 20, 1);
1771 for x in 0u16..2 {
1773 let cell = term.cell((x, 0)).unwrap();
1774 assert_eq!(cell.fg, Color::Magenta, "col {x}: fg from narrow span");
1775 assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1776 }
1777 for x in 2u16..12 {
1779 let cell = term.cell((x, 0)).unwrap();
1780 assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1781 assert_eq!(
1782 cell.fg,
1783 Color::Reset,
1784 "col {x}: no fg set (broad span is bg-only)"
1785 );
1786 }
1787 }
1788
1789 #[test]
1790 fn narrow_span_with_explicit_bg_still_overrides_broad_bg() {
1791 use hjkl_buffer::Span;
1796 let b = Buffer::from_str("hello world");
1797 let v = vp(20, 1);
1798 let spans = vec![vec![
1799 Span::new(0, 11, 1), Span::new(6, 11, 2), ]];
1802 let resolver = |id: u32| -> Style {
1803 match id {
1804 1 => Style::default().bg(Color::DarkGray),
1805 2 => Style::default().bg(Color::Red),
1806 _ => Style::default(),
1807 }
1808 };
1809 let view = BufferView {
1810 buffer: &b,
1811 viewport: &v,
1812 selection: None,
1813 resolver: &resolver,
1814 cursor_line_bg: Style::default(),
1815 cursor_column_bg: Style::default(),
1816 selection_bg: Style::default().bg(Color::Blue),
1817 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1818 gutter: None,
1819 search_bg: Style::default(),
1820 signs: &[],
1821 conceals: &[],
1822 spans: &spans,
1823 search_pattern: None,
1824 non_text_style: Style::default(),
1825 diag_overlays: &[],
1826 colorcolumn_cols: &[],
1827 colorcolumn_style: Style::default(),
1828 listchars: None,
1829 indent_guides_enabled: false,
1830 indent_guide_char: '│',
1831 indent_guide_shiftwidth: 4,
1832 indent_guide_fg: Color::Reset,
1833 indent_guide_active_fg: Color::Reset,
1834 indent_guide_active_col: None,
1835 fold_line_bg: Style::default(),
1836 eol_hints: &[],
1837 blame_plan: None,
1838 };
1839 let term = run_render(view, 20, 1);
1840 for x in 0u16..6 {
1842 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::DarkGray);
1843 }
1844 for x in 6u16..11 {
1846 assert_eq!(
1847 term.cell((x, 0)).unwrap().bg,
1848 Color::Red,
1849 "col {x}: narrow span's bg overrides broad bg"
1850 );
1851 }
1852 }
1853
1854 #[test]
1855 fn syntax_span_fg_resolves_via_table() {
1856 use hjkl_buffer::Span;
1857 let b = Buffer::from_str("SELECT foo");
1858 let v = vp(20, 1);
1859 let spans = vec![vec![Span::new(0, 6, 7)]];
1860 let resolver = |id: u32| -> Style {
1861 if id == 7 {
1862 Style::default().fg(Color::Red)
1863 } else {
1864 Style::default()
1865 }
1866 };
1867 let view = BufferView {
1868 buffer: &b,
1869 viewport: &v,
1870 selection: None,
1871 resolver: &resolver,
1872 cursor_line_bg: Style::default(),
1873 cursor_column_bg: Style::default(),
1874 selection_bg: Style::default().bg(Color::Blue),
1875 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1876 gutter: None,
1877 search_bg: Style::default(),
1878 signs: &[],
1879 conceals: &[],
1880 spans: &spans,
1881 search_pattern: None,
1882 non_text_style: Style::default(),
1883 diag_overlays: &[],
1884 colorcolumn_cols: &[],
1885 colorcolumn_style: Style::default(),
1886 listchars: None,
1887 indent_guides_enabled: false,
1888 indent_guide_char: '│',
1889 indent_guide_shiftwidth: 4,
1890 indent_guide_fg: Color::Reset,
1891 indent_guide_active_fg: Color::Reset,
1892 indent_guide_active_col: None,
1893 fold_line_bg: Style::default(),
1894 eol_hints: &[],
1895 blame_plan: None,
1896 };
1897 let term = run_render(view, 20, 1);
1898 for x in 0..6 {
1899 assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1900 }
1901 }
1902
1903 #[test]
1904 fn gutter_renders_right_aligned_line_numbers() {
1905 let b = Buffer::from_str("a\nb\nc");
1906 let v = vp(10, 3);
1907 let view = BufferView {
1908 buffer: &b,
1909 viewport: &v,
1910 selection: None,
1911 resolver: &(no_styles as fn(u32) -> Style),
1912 cursor_line_bg: Style::default(),
1913 cursor_column_bg: Style::default(),
1914 selection_bg: Style::default().bg(Color::Blue),
1915 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1916 gutter: Some(Gutter {
1917 width: 4,
1918 style: Style::default().fg(Color::Yellow),
1919 line_offset: 0,
1920 ..Default::default()
1921 }),
1922 search_bg: Style::default(),
1923 signs: &[],
1924 conceals: &[],
1925 spans: &[],
1926 search_pattern: None,
1927 non_text_style: Style::default(),
1928 diag_overlays: &[],
1929 colorcolumn_cols: &[],
1930 colorcolumn_style: Style::default(),
1931 listchars: None,
1932 indent_guides_enabled: false,
1933 indent_guide_char: '│',
1934 indent_guide_shiftwidth: 4,
1935 indent_guide_fg: Color::Reset,
1936 indent_guide_active_fg: Color::Reset,
1937 indent_guide_active_col: None,
1938 fold_line_bg: Style::default(),
1939 eol_hints: &[],
1940 blame_plan: None,
1941 };
1942 let term = run_render(view, 10, 3);
1943 assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1945 assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
1946 assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1947 assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
1948 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1950 }
1951
1952 #[test]
1953 fn gutter_renders_relative_with_cursor_at_zero() {
1954 let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1956 b.set_cursor(hjkl_buffer::Position::new(2, 0));
1957 let v = vp(10, 5);
1958 let view = BufferView {
1959 buffer: &b,
1960 viewport: &v,
1961 selection: None,
1962 resolver: &(no_styles as fn(u32) -> Style),
1963 cursor_line_bg: Style::default(),
1964 cursor_column_bg: Style::default(),
1965 selection_bg: Style::default().bg(Color::Blue),
1966 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1967 gutter: Some(Gutter {
1968 width: 4,
1969 style: Style::default().fg(Color::Yellow),
1970 line_offset: 0,
1971 numbers: GutterNumbers::Relative { cursor_row: 2 },
1972 sign_column_width: 0,
1973 fold_column_width: 0,
1974 }),
1975 search_bg: Style::default(),
1976 signs: &[],
1977 conceals: &[],
1978 spans: &[],
1979 search_pattern: None,
1980 non_text_style: Style::default(),
1981 diag_overlays: &[],
1982 colorcolumn_cols: &[],
1983 colorcolumn_style: Style::default(),
1984 listchars: None,
1985 indent_guides_enabled: false,
1986 indent_guide_char: '│',
1987 indent_guide_shiftwidth: 4,
1988 indent_guide_fg: Color::Reset,
1989 indent_guide_active_fg: Color::Reset,
1990 indent_guide_active_col: None,
1991 fold_line_bg: Style::default(),
1992 eol_hints: &[],
1993 blame_plan: None,
1994 };
1995 let term = run_render(view, 10, 5);
1996 assert_eq!(term.cell((2, 0)).unwrap().symbol(), "2");
1999 assert_eq!(term.cell((2, 1)).unwrap().symbol(), "1");
2001 assert_eq!(term.cell((2, 2)).unwrap().symbol(), "0");
2003 assert_eq!(term.cell((2, 3)).unwrap().symbol(), "1");
2005 assert_eq!(term.cell((2, 4)).unwrap().symbol(), "2");
2007 }
2008
2009 #[test]
2010 fn gutter_renders_hybrid_cursor_row_absolute() {
2011 let mut b = Buffer::from_str("a\nb\nc");
2014 b.set_cursor(hjkl_buffer::Position::new(1, 0));
2015 let v = vp(10, 3);
2016 let view = BufferView {
2017 buffer: &b,
2018 viewport: &v,
2019 selection: None,
2020 resolver: &(no_styles as fn(u32) -> Style),
2021 cursor_line_bg: Style::default(),
2022 cursor_column_bg: Style::default(),
2023 selection_bg: Style::default().bg(Color::Blue),
2024 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2025 gutter: Some(Gutter {
2026 width: 4,
2027 style: Style::default().fg(Color::Yellow),
2028 line_offset: 0,
2029 numbers: GutterNumbers::Hybrid { cursor_row: 1 },
2030 sign_column_width: 0,
2031 fold_column_width: 0,
2032 }),
2033 search_bg: Style::default(),
2034 signs: &[],
2035 conceals: &[],
2036 spans: &[],
2037 search_pattern: None,
2038 non_text_style: Style::default(),
2039 diag_overlays: &[],
2040 colorcolumn_cols: &[],
2041 colorcolumn_style: Style::default(),
2042 listchars: None,
2043 indent_guides_enabled: false,
2044 indent_guide_char: '│',
2045 indent_guide_shiftwidth: 4,
2046 indent_guide_fg: Color::Reset,
2047 indent_guide_active_fg: Color::Reset,
2048 indent_guide_active_col: None,
2049 fold_line_bg: Style::default(),
2050 eol_hints: &[],
2051 blame_plan: None,
2052 };
2053 let term = run_render(view, 10, 3);
2054 assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
2056 assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
2058 assert_eq!(term.cell((2, 2)).unwrap().symbol(), "1");
2060 }
2061
2062 #[test]
2063 fn gutter_none_paints_blank_cells() {
2064 let b = Buffer::from_str("a\nb\nc");
2065 let v = vp(10, 3);
2066 let view = BufferView {
2067 buffer: &b,
2068 viewport: &v,
2069 selection: None,
2070 resolver: &(no_styles as fn(u32) -> Style),
2071 cursor_line_bg: Style::default(),
2072 cursor_column_bg: Style::default(),
2073 selection_bg: Style::default().bg(Color::Blue),
2074 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2075 gutter: Some(Gutter {
2076 width: 4,
2077 style: Style::default().fg(Color::Yellow),
2078 line_offset: 0,
2079 numbers: GutterNumbers::None,
2080 sign_column_width: 0,
2081 fold_column_width: 0,
2082 }),
2083 search_bg: Style::default(),
2084 signs: &[],
2085 conceals: &[],
2086 spans: &[],
2087 search_pattern: None,
2088 non_text_style: Style::default(),
2089 diag_overlays: &[],
2090 colorcolumn_cols: &[],
2091 colorcolumn_style: Style::default(),
2092 listchars: None,
2093 indent_guides_enabled: false,
2094 indent_guide_char: '│',
2095 indent_guide_shiftwidth: 4,
2096 indent_guide_fg: Color::Reset,
2097 indent_guide_active_fg: Color::Reset,
2098 indent_guide_active_col: None,
2099 fold_line_bg: Style::default(),
2100 eol_hints: &[],
2101 blame_plan: None,
2102 };
2103 let term = run_render(view, 10, 3);
2104 for row in 0..3u16 {
2106 for x in 0..4u16 {
2107 assert_eq!(
2108 term.cell((x, row)).unwrap().symbol(),
2109 " ",
2110 "expected blank at ({x}, {row})"
2111 );
2112 }
2113 }
2114 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
2116 }
2117
2118 #[test]
2119 fn search_bg_paints_match_cells() {
2120 use regex::Regex;
2121 let b = Buffer::from_str("foo bar foo");
2122 let v = vp(20, 1);
2123 let pat = Regex::new("foo").unwrap();
2124 let view = BufferView {
2125 buffer: &b,
2126 viewport: &v,
2127 selection: None,
2128 resolver: &(no_styles as fn(u32) -> Style),
2129 cursor_line_bg: Style::default(),
2130 cursor_column_bg: Style::default(),
2131 selection_bg: Style::default().bg(Color::Blue),
2132 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2133 gutter: None,
2134 search_bg: Style::default().bg(Color::Magenta),
2135 signs: &[],
2136 conceals: &[],
2137 spans: &[],
2138 search_pattern: Some(&pat),
2139 non_text_style: Style::default(),
2140 diag_overlays: &[],
2141 colorcolumn_cols: &[],
2142 colorcolumn_style: Style::default(),
2143 listchars: None,
2144 indent_guides_enabled: false,
2145 indent_guide_char: '│',
2146 indent_guide_shiftwidth: 4,
2147 indent_guide_fg: Color::Reset,
2148 indent_guide_active_fg: Color::Reset,
2149 indent_guide_active_col: None,
2150 fold_line_bg: Style::default(),
2151 eol_hints: &[],
2152 blame_plan: None,
2153 };
2154 let term = run_render(view, 20, 1);
2155 for x in 0..3 {
2156 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2157 }
2158 assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
2160 for x in 8..11 {
2161 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2162 }
2163 }
2164
2165 #[test]
2166 fn search_bg_survives_cursorcolumn_overlay() {
2167 use regex::Regex;
2168 let mut b = Buffer::from_str("foo bar foo");
2172 let v = vp(20, 1);
2173 let pat = Regex::new("foo").unwrap();
2174 b.set_cursor(hjkl_buffer::Position::new(0, 1));
2176 let view = BufferView {
2177 buffer: &b,
2178 viewport: &v,
2179 selection: None,
2180 resolver: &(no_styles as fn(u32) -> Style),
2181 cursor_line_bg: Style::default(),
2182 cursor_column_bg: Style::default().bg(Color::DarkGray),
2183 selection_bg: Style::default().bg(Color::Blue),
2184 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2185 gutter: None,
2186 search_bg: Style::default().bg(Color::Magenta),
2187 signs: &[],
2188 conceals: &[],
2189 spans: &[],
2190 search_pattern: Some(&pat),
2191 non_text_style: Style::default(),
2192 diag_overlays: &[],
2193 colorcolumn_cols: &[],
2194 colorcolumn_style: Style::default(),
2195 listchars: None,
2196 indent_guides_enabled: false,
2197 indent_guide_char: '│',
2198 indent_guide_shiftwidth: 4,
2199 indent_guide_fg: Color::Reset,
2200 indent_guide_active_fg: Color::Reset,
2201 indent_guide_active_col: None,
2202 fold_line_bg: Style::default(),
2203 eol_hints: &[],
2204 blame_plan: None,
2205 };
2206 let term = run_render(view, 20, 1);
2207 assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
2209 }
2210
2211 #[test]
2212 fn highest_priority_sign_wins_per_row_in_dedicated_sign_column() {
2213 let b = Buffer::from_str("a\nb\nc");
2216 let v = vp(10, 3);
2217 let signs = [
2218 Sign {
2219 row: 0,
2220 ch: 'W',
2221 style: Style::default().fg(Color::Yellow),
2222 priority: 1,
2223 },
2224 Sign {
2225 row: 0,
2226 ch: 'E',
2227 style: Style::default().fg(Color::Red),
2228 priority: 2,
2229 },
2230 ];
2231 let view = BufferView {
2232 buffer: &b,
2233 viewport: &v,
2234 selection: None,
2235 resolver: &(no_styles as fn(u32) -> Style),
2236 cursor_line_bg: Style::default(),
2237 cursor_column_bg: Style::default(),
2238 selection_bg: Style::default().bg(Color::Blue),
2239 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2240 gutter: Some(Gutter {
2241 width: 3,
2242 style: Style::default().fg(Color::DarkGray),
2243 line_offset: 0,
2244 sign_column_width: 1,
2245 fold_column_width: 0,
2246 ..Default::default()
2247 }),
2248 search_bg: Style::default(),
2249 signs: &signs,
2250 conceals: &[],
2251 spans: &[],
2252 search_pattern: None,
2253 non_text_style: Style::default(),
2254 diag_overlays: &[],
2255 colorcolumn_cols: &[],
2256 colorcolumn_style: Style::default(),
2257 listchars: None,
2258 indent_guides_enabled: false,
2259 indent_guide_char: '│',
2260 indent_guide_shiftwidth: 4,
2261 indent_guide_fg: Color::Reset,
2262 indent_guide_active_fg: Color::Reset,
2263 indent_guide_active_col: None,
2264 fold_line_bg: Style::default(),
2265 eol_hints: &[],
2266 blame_plan: None,
2267 };
2268 let term = run_render(view, 10, 3);
2269 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
2271 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2272 assert_ne!(term.cell((1, 0)).unwrap().symbol(), "E");
2274 assert_eq!(term.cell((0, 1)).unwrap().symbol(), " ");
2276 }
2277
2278 #[test]
2279 fn conceal_replaces_byte_range() {
2280 let b = Buffer::from_str("see https://example.com end");
2281 let v = vp(30, 1);
2282 let conceals = vec![Conceal {
2283 row: 0,
2284 start_byte: 4, end_byte: 4 + "https://example.com".len(), replacement: "🔗".to_string(),
2287 }];
2288 let view = BufferView {
2289 buffer: &b,
2290 viewport: &v,
2291 selection: None,
2292 resolver: &(no_styles as fn(u32) -> Style),
2293 cursor_line_bg: Style::default(),
2294 cursor_column_bg: Style::default(),
2295 selection_bg: Style::default(),
2296 cursor_style: Style::default(),
2297 gutter: None,
2298 search_bg: Style::default(),
2299 signs: &[],
2300 conceals: &conceals,
2301 spans: &[],
2302 search_pattern: None,
2303 non_text_style: Style::default(),
2304 diag_overlays: &[],
2305 colorcolumn_cols: &[],
2306 colorcolumn_style: Style::default(),
2307 listchars: None,
2308 indent_guides_enabled: false,
2309 indent_guide_char: '│',
2310 indent_guide_shiftwidth: 4,
2311 indent_guide_fg: Color::Reset,
2312 indent_guide_active_fg: Color::Reset,
2313 indent_guide_active_col: None,
2314 fold_line_bg: Style::default(),
2315 eol_hints: &[],
2316 blame_plan: None,
2317 };
2318 let term = run_render(view, 30, 1);
2319 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
2321 assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
2322 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
2325 }
2326
2327 #[test]
2330 fn closed_fold_renders_first_line_content_with_fold_bg() {
2331 let mut b = Buffer::from_str("a\nb\nc\nd\ne");
2332 let v = vp(30, 5);
2333 b.add_fold(1, 3, true);
2335 let fold_bg = Color::Rgb(0x3a, 0x4a, 0x5a);
2336 let view = BufferView {
2337 buffer: &b,
2338 viewport: &v,
2339 selection: None,
2340 resolver: &(no_styles as fn(u32) -> Style),
2341 cursor_line_bg: Style::default(),
2342 fold_line_bg: Style::default().bg(fold_bg),
2343 cursor_column_bg: Style::default(),
2344 selection_bg: Style::default().bg(Color::Blue),
2345 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2346 gutter: None,
2347 search_bg: Style::default(),
2348 signs: &[],
2349 conceals: &[],
2350 spans: &[],
2351 search_pattern: None,
2352 non_text_style: Style::default(),
2353 diag_overlays: &[],
2354 colorcolumn_cols: &[],
2355 colorcolumn_style: Style::default(),
2356 listchars: None,
2357 indent_guides_enabled: false,
2358 indent_guide_char: '│',
2359 indent_guide_shiftwidth: 4,
2360 indent_guide_fg: Color::Reset,
2361 indent_guide_active_fg: Color::Reset,
2362 indent_guide_active_col: None,
2363 eol_hints: &[],
2364 blame_plan: None,
2365 };
2366 let term = run_render(view, 30, 5);
2367 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2369 assert_ne!(
2370 term.cell((0, 0)).unwrap().bg,
2371 fold_bg,
2372 "row 0 is not a fold header and must NOT carry fold_bg"
2373 );
2374 assert_eq!(
2377 term.cell((0, 1)).unwrap().symbol(),
2378 "b",
2379 "fold header row must show the first line's real content, not summary text"
2380 );
2381 assert_eq!(
2382 term.cell((0, 1)).unwrap().bg,
2383 fold_bg,
2384 "fold header row must carry fold_line_bg"
2385 );
2386 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
2388 }
2389
2390 #[test]
2391 fn open_fold_renders_normally() {
2392 let mut b = Buffer::from_str("a\nb\nc");
2393 let v = vp(5, 3);
2394 b.add_fold(0, 2, false); let view = BufferView {
2396 buffer: &b,
2397 viewport: &v,
2398 selection: None,
2399 resolver: &(no_styles as fn(u32) -> Style),
2400 cursor_line_bg: Style::default(),
2401 cursor_column_bg: Style::default(),
2402 selection_bg: Style::default().bg(Color::Blue),
2403 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2404 gutter: None,
2405 search_bg: Style::default(),
2406 signs: &[],
2407 conceals: &[],
2408 spans: &[],
2409 search_pattern: None,
2410 non_text_style: Style::default(),
2411 diag_overlays: &[],
2412 colorcolumn_cols: &[],
2413 colorcolumn_style: Style::default(),
2414 listchars: None,
2415 indent_guides_enabled: false,
2416 indent_guide_char: '│',
2417 indent_guide_shiftwidth: 4,
2418 indent_guide_fg: Color::Reset,
2419 indent_guide_active_fg: Color::Reset,
2420 indent_guide_active_col: None,
2421 fold_line_bg: Style::default(),
2422 eol_hints: &[],
2423 blame_plan: None,
2424 };
2425 let term = run_render(view, 5, 3);
2426 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2427 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
2428 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
2429 }
2430
2431 #[test]
2432 fn horizontal_scroll_clips_left_chars() {
2433 let b = Buffer::from_str("abcdefgh");
2434 let mut v = vp(4, 1);
2435 v.top_col = 3;
2436 let view = BufferView {
2437 buffer: &b,
2438 viewport: &v,
2439 selection: None,
2440 resolver: &(no_styles as fn(u32) -> Style),
2441 cursor_line_bg: Style::default(),
2442 cursor_column_bg: Style::default(),
2443 selection_bg: Style::default().bg(Color::Blue),
2444 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2445 gutter: None,
2446 search_bg: Style::default(),
2447 signs: &[],
2448 conceals: &[],
2449 spans: &[],
2450 search_pattern: None,
2451 non_text_style: Style::default(),
2452 diag_overlays: &[],
2453 colorcolumn_cols: &[],
2454 colorcolumn_style: Style::default(),
2455 listchars: None,
2456 indent_guides_enabled: false,
2457 indent_guide_char: '│',
2458 indent_guide_shiftwidth: 4,
2459 indent_guide_fg: Color::Reset,
2460 indent_guide_active_fg: Color::Reset,
2461 indent_guide_active_col: None,
2462 fold_line_bg: Style::default(),
2463 eol_hints: &[],
2464 blame_plan: None,
2465 };
2466 let term = run_render(view, 4, 1);
2467 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
2468 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
2469 }
2470
2471 fn make_wrap_view<'a>(
2472 b: &'a Buffer,
2473 viewport: &'a Viewport,
2474 resolver: &'a (impl StyleResolver + 'a),
2475 gutter: Option<Gutter>,
2476 ) -> BufferView<'a, impl StyleResolver + 'a> {
2477 BufferView {
2478 buffer: b,
2479 viewport,
2480 selection: None,
2481 resolver,
2482 cursor_line_bg: Style::default(),
2483 fold_line_bg: Style::default(),
2484 cursor_column_bg: Style::default(),
2485 selection_bg: Style::default().bg(Color::Blue),
2486 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2487 gutter,
2488 search_bg: Style::default(),
2489 signs: &[],
2490 conceals: &[],
2491 spans: &[],
2492 search_pattern: None,
2493 non_text_style: Style::default(),
2494 diag_overlays: &[],
2495 colorcolumn_cols: &[],
2496 colorcolumn_style: Style::default(),
2497 listchars: None,
2498 indent_guides_enabled: false,
2499 indent_guide_char: '│',
2500 indent_guide_shiftwidth: 4,
2501 indent_guide_fg: Color::Reset,
2502 indent_guide_active_fg: Color::Reset,
2503 indent_guide_active_col: None,
2504 eol_hints: &[],
2505 blame_plan: None,
2506 }
2507 }
2508
2509 #[test]
2510 fn wrap_segments_char_breaks_at_width() {
2511 let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
2512 assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
2513 }
2514
2515 #[test]
2516 fn wrap_segments_word_backs_up_to_whitespace() {
2517 let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
2518 assert_eq!(segs[0], (0, 6));
2520 assert_eq!(segs[1], (6, 11));
2522 assert_eq!(segs[2], (11, 16));
2523 }
2524
2525 #[test]
2526 fn wrap_segments_word_falls_back_to_char_for_long_runs() {
2527 let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
2528 assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
2530 }
2531
2532 #[test]
2533 fn wrap_char_paints_continuation_rows() {
2534 let b = Buffer::from_str("abcdefghij");
2535 let v = Viewport {
2536 top_row: 0,
2537 top_col: 0,
2538 width: 4,
2539 height: 3,
2540 wrap: Wrap::Char,
2541 text_width: 4,
2542 tab_width: 0,
2543 };
2544 let r = no_styles as fn(u32) -> Style;
2545 let view = make_wrap_view(&b, &v, &r, None);
2546 let term = run_render(view, 4, 3);
2547 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2549 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
2550 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
2552 assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
2553 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
2555 assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
2556 }
2557
2558 #[test]
2559 fn wrap_char_gutter_blank_on_continuation() {
2560 let b = Buffer::from_str("abcdefgh");
2561 let v = Viewport {
2562 top_row: 0,
2563 top_col: 0,
2564 width: 6,
2565 height: 3,
2566 wrap: Wrap::Char,
2567 text_width: 3,
2569 tab_width: 0,
2570 };
2571 let r = no_styles as fn(u32) -> Style;
2572 let gutter = Gutter {
2573 width: 3,
2574 style: Style::default().fg(Color::Yellow),
2575 line_offset: 0,
2576 ..Default::default()
2577 };
2578 let view = make_wrap_view(&b, &v, &r, Some(gutter));
2579 let term = run_render(view, 6, 3);
2580 assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
2582 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
2583 for x in 0..2 {
2585 assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
2586 }
2587 assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
2588 assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
2589 }
2590
2591 #[test]
2592 fn wrap_char_cursor_lands_on_correct_segment() {
2593 let mut b = Buffer::from_str("abcdefghij");
2594 let v = Viewport {
2595 top_row: 0,
2596 top_col: 0,
2597 width: 4,
2598 height: 3,
2599 wrap: Wrap::Char,
2600 text_width: 4,
2601 tab_width: 0,
2602 };
2603 b.set_cursor(hjkl_buffer::Position::new(0, 6));
2605 let r = no_styles as fn(u32) -> Style;
2606 let mut view = make_wrap_view(&b, &v, &r, None);
2607 view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
2608 let term = run_render(view, 4, 3);
2609 assert!(
2610 term.cell((2, 1))
2611 .unwrap()
2612 .modifier
2613 .contains(Modifier::REVERSED)
2614 );
2615 }
2616
2617 #[test]
2618 fn wrap_char_eol_cursor_placeholder_on_last_segment() {
2619 let mut b = Buffer::from_str("abcdef");
2620 let v = Viewport {
2621 top_row: 0,
2622 top_col: 0,
2623 width: 4,
2624 height: 3,
2625 wrap: Wrap::Char,
2626 text_width: 4,
2627 tab_width: 0,
2628 };
2629 b.set_cursor(hjkl_buffer::Position::new(0, 6));
2631 let r = no_styles as fn(u32) -> Style;
2632 let mut view = make_wrap_view(&b, &v, &r, None);
2633 view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
2634 let term = run_render(view, 4, 3);
2635 assert!(
2637 term.cell((2, 1))
2638 .unwrap()
2639 .modifier
2640 .contains(Modifier::REVERSED)
2641 );
2642 }
2643
2644 #[test]
2645 fn wrap_word_breaks_at_whitespace() {
2646 let b = Buffer::from_str("alpha beta gamma");
2647 let v = Viewport {
2648 top_row: 0,
2649 top_col: 0,
2650 width: 8,
2651 height: 3,
2652 wrap: Wrap::Word,
2653 text_width: 8,
2654 tab_width: 0,
2655 };
2656 let r = no_styles as fn(u32) -> Style;
2657 let view = make_wrap_view(&b, &v, &r, None);
2658 let term = run_render(view, 8, 3);
2659 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2661 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
2662 assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
2664 assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
2665 assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
2667 assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
2668 }
2669
2670 fn view_with<'a>(
2676 b: &'a Buffer,
2677 viewport: &'a Viewport,
2678 resolver: &'a (impl StyleResolver + 'a),
2679 spans: &'a [Vec<Span>],
2680 search_pattern: Option<&'a regex::Regex>,
2681 ) -> BufferView<'a, impl StyleResolver + 'a> {
2682 BufferView {
2683 buffer: b,
2684 viewport,
2685 selection: None,
2686 resolver,
2687 cursor_line_bg: Style::default(),
2688 fold_line_bg: Style::default(),
2689 cursor_column_bg: Style::default(),
2690 selection_bg: Style::default().bg(Color::Blue),
2691 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2692 gutter: None,
2693 search_bg: Style::default().bg(Color::Magenta),
2694 signs: &[],
2695 conceals: &[],
2696 spans,
2697 search_pattern,
2698 non_text_style: Style::default(),
2699 diag_overlays: &[],
2700 colorcolumn_cols: &[],
2701 colorcolumn_style: Style::default(),
2702 listchars: None,
2703 indent_guides_enabled: false,
2704 indent_guide_char: '│',
2705 indent_guide_shiftwidth: 4,
2706 indent_guide_fg: Color::Reset,
2707 indent_guide_active_fg: Color::Reset,
2708 indent_guide_active_col: None,
2709 eol_hints: &[],
2710 blame_plan: None,
2711 }
2712 }
2713
2714 #[test]
2715 fn empty_spans_param_renders_default_style() {
2716 let b = Buffer::from_str("hello");
2717 let v = vp(10, 1);
2718 let r = no_styles as fn(u32) -> Style;
2719 let view = view_with(&b, &v, &r, &[], None);
2720 let term = run_render(view, 10, 1);
2721 assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
2722 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
2723 }
2724
2725 #[test]
2726 fn spans_param_paints_styled_byte_range() {
2727 let b = Buffer::from_str("abcdef");
2728 let v = vp(10, 1);
2729 let resolver = |id: u32| -> Style {
2730 if id == 3 {
2731 Style::default().fg(Color::Green)
2732 } else {
2733 Style::default()
2734 }
2735 };
2736 let spans = vec![vec![Span::new(0, 3, 3)]];
2737 let view = view_with(&b, &v, &resolver, &spans, None);
2738 let term = run_render(view, 10, 1);
2739 for x in 0..3 {
2740 assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
2741 }
2742 assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
2743 }
2744
2745 #[test]
2746 fn spans_param_handles_per_row_overlay() {
2747 let b = Buffer::from_str("abc\ndef");
2748 let v = vp(10, 2);
2749 let resolver = |id: u32| -> Style {
2750 if id == 1 {
2751 Style::default().fg(Color::Red)
2752 } else {
2753 Style::default().fg(Color::Green)
2754 }
2755 };
2756 let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
2757 let view = view_with(&b, &v, &resolver, &spans, None);
2758 let term = run_render(view, 10, 2);
2759 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2760 assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
2761 }
2762
2763 #[test]
2764 fn spans_param_rows_beyond_get_no_styling() {
2765 let b = Buffer::from_str("abc\ndef\nghi");
2766 let v = vp(10, 3);
2767 let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
2768 let spans = vec![vec![Span::new(0, 3, 0)]];
2770 let view = view_with(&b, &v, &resolver, &spans, None);
2771 let term = run_render(view, 10, 3);
2772 assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2773 assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
2774 assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
2775 }
2776
2777 #[test]
2778 fn search_pattern_none_disables_hlsearch() {
2779 let b = Buffer::from_str("foo bar foo");
2780 let v = vp(20, 1);
2781 let r = no_styles as fn(u32) -> Style;
2782 let view = view_with(&b, &v, &r, &[], None);
2784 let term = run_render(view, 20, 1);
2785 for x in 0..11 {
2786 assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2787 }
2788 }
2789
2790 #[test]
2791 fn search_pattern_regex_paints_match_bg() {
2792 use regex::Regex;
2793 let b = Buffer::from_str("xyz foo xyz");
2794 let v = vp(20, 1);
2795 let r = no_styles as fn(u32) -> Style;
2796 let pat = Regex::new("foo").unwrap();
2797 let view = view_with(&b, &v, &r, &[], Some(&pat));
2798 let term = run_render(view, 20, 1);
2799 assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
2801 for x in 4..7 {
2802 assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2803 }
2804 assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
2805 }
2806
2807 #[test]
2808 fn search_pattern_unicode_columns_are_charwise() {
2809 use regex::Regex;
2810 let b = Buffer::from_str("tablé foo");
2812 let v = vp(20, 1);
2813 let r = no_styles as fn(u32) -> Style;
2814 let pat = Regex::new("foo").unwrap();
2815 let view = view_with(&b, &v, &r, &[], Some(&pat));
2816 let term = run_render(view, 20, 1);
2817 assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
2819 assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
2820 assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
2821 }
2822
2823 #[test]
2824 fn spans_param_clamps_short_row_overlay() {
2825 let b = Buffer::from_str("abc");
2827 let v = vp(10, 1);
2828 let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
2829 let spans = vec![vec![Span::new(0, 100, 0)]];
2830 let view = view_with(&b, &v, &resolver, &spans, None);
2831 let term = run_render(view, 10, 1);
2832 for x in 0..3 {
2833 assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
2834 }
2835 }
2836
2837 #[test]
2838 fn spans_and_search_pattern_compose() {
2839 use regex::Regex;
2841 let b = Buffer::from_str("foo");
2842 let v = vp(10, 1);
2843 let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
2844 let spans = vec![vec![Span::new(0, 3, 0)]];
2845 let pat = Regex::new("foo").unwrap();
2846 let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
2847 let term = run_render(view, 10, 1);
2848 let cell = term.cell((1, 0)).unwrap();
2849 assert_eq!(cell.fg, Color::Green);
2850 assert_eq!(cell.bg, Color::Magenta);
2851 }
2852
2853 #[test]
2857 fn tilde_marker_painted_past_eof() {
2858 let b = Buffer::from_str("a\nb\nc\nd\ne");
2860 let v = vp(10, 10);
2861 let r = no_styles as fn(u32) -> Style;
2862 let non_text_fg = Color::DarkGray;
2863 let view = BufferView {
2864 buffer: &b,
2865 viewport: &v,
2866 selection: None,
2867 resolver: &r,
2868 cursor_line_bg: Style::default(),
2869 cursor_column_bg: Style::default(),
2870 selection_bg: Style::default().bg(Color::Blue),
2871 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2872 gutter: None,
2873 search_bg: Style::default(),
2874 signs: &[],
2875 conceals: &[],
2876 spans: &[],
2877 search_pattern: None,
2878 non_text_style: Style::default().fg(non_text_fg),
2879 diag_overlays: &[],
2880 colorcolumn_cols: &[],
2881 colorcolumn_style: Style::default(),
2882 listchars: None,
2883 indent_guides_enabled: false,
2884 indent_guide_char: '│',
2885 indent_guide_shiftwidth: 4,
2886 indent_guide_fg: Color::Reset,
2887 indent_guide_active_fg: Color::Reset,
2888 indent_guide_active_col: None,
2889 fold_line_bg: Style::default(),
2890 eol_hints: &[],
2891 blame_plan: None,
2892 };
2893 let term = run_render(view, 10, 10);
2894 for row in 0..5u16 {
2896 assert_ne!(
2897 term.cell((0, row)).unwrap().symbol(),
2898 "~",
2899 "row {row} is a content row, expected no tilde"
2900 );
2901 }
2902 for row in 5..10u16 {
2904 let cell = term.cell((0, row)).unwrap();
2905 assert_eq!(cell.symbol(), "~", "row {row} is past EOF, expected tilde");
2906 assert_eq!(
2907 cell.fg, non_text_fg,
2908 "row {row} tilde should use non_text_style fg"
2909 );
2910 for x in 1..10u16 {
2912 assert_eq!(
2913 term.cell((x, row)).unwrap().symbol(),
2914 " ",
2915 "row {row} col {x} after tilde should be blank"
2916 );
2917 }
2918 }
2919 }
2920
2921 #[test]
2924 fn tilde_marker_with_gutter_past_eof() {
2925 let b = Buffer::from_str("a\nb");
2926 let v = vp(10, 5);
2927 let r = no_styles as fn(u32) -> Style;
2928 let non_text_fg = Color::DarkGray;
2929 let view = BufferView {
2930 buffer: &b,
2931 viewport: &v,
2932 selection: None,
2933 resolver: &r,
2934 cursor_line_bg: Style::default(),
2935 cursor_column_bg: Style::default(),
2936 selection_bg: Style::default().bg(Color::Blue),
2937 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2938 gutter: Some(Gutter {
2939 width: 4,
2940 style: Style::default().fg(Color::Yellow),
2941 line_offset: 0,
2942 numbers: GutterNumbers::Absolute,
2943 sign_column_width: 0,
2944 fold_column_width: 0,
2945 }),
2946 search_bg: Style::default(),
2947 signs: &[],
2948 conceals: &[],
2949 spans: &[],
2950 search_pattern: None,
2951 non_text_style: Style::default().fg(non_text_fg),
2952 diag_overlays: &[],
2953 colorcolumn_cols: &[],
2954 colorcolumn_style: Style::default(),
2955 listchars: None,
2956 indent_guides_enabled: false,
2957 indent_guide_char: '│',
2958 indent_guide_shiftwidth: 4,
2959 indent_guide_fg: Color::Reset,
2960 indent_guide_active_fg: Color::Reset,
2961 indent_guide_active_col: None,
2962 fold_line_bg: Style::default(),
2963 eol_hints: &[],
2964 blame_plan: None,
2965 };
2966 let term = run_render(view, 10, 5);
2967 for row in 2..5u16 {
2969 for x in 0..4u16 {
2971 assert_eq!(
2972 term.cell((x, row)).unwrap().symbol(),
2973 " ",
2974 "gutter col {x} on past-EOF row {row} should be blank"
2975 );
2976 }
2977 let cell = term.cell((4, row)).unwrap();
2979 assert_eq!(
2980 cell.symbol(),
2981 "~",
2982 "past-EOF row {row}: expected tilde at text column"
2983 );
2984 assert_eq!(cell.fg, non_text_fg);
2985 }
2986 }
2987
2988 #[test]
2989 fn diag_overlay_paints_underline_on_range() {
2990 let b = Buffer::from_str("hello world");
2994 let v = vp(20, 2);
2995 let overlay = DiagOverlay {
2996 row: 0,
2997 col_start: 6,
2998 col_end: 11,
2999 style: Style::default().add_modifier(Modifier::UNDERLINED),
3000 };
3001 let view = BufferView {
3002 buffer: &b,
3003 viewport: &v,
3004 selection: None,
3005 resolver: &(no_styles as fn(u32) -> Style),
3006 cursor_line_bg: Style::default(),
3007 cursor_column_bg: Style::default(),
3008 selection_bg: Style::default().bg(Color::Blue),
3009 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3010 gutter: None,
3011 search_bg: Style::default(),
3012 signs: &[],
3013 conceals: &[],
3014 spans: &[],
3015 search_pattern: None,
3016 non_text_style: Style::default(),
3017 diag_overlays: &[overlay],
3018 colorcolumn_cols: &[],
3019 colorcolumn_style: Style::default(),
3020 listchars: None,
3021 indent_guides_enabled: false,
3022 indent_guide_char: '│',
3023 indent_guide_shiftwidth: 4,
3024 indent_guide_fg: Color::Reset,
3025 indent_guide_active_fg: Color::Reset,
3026 indent_guide_active_col: None,
3027 fold_line_bg: Style::default(),
3028 eol_hints: &[],
3029 blame_plan: None,
3030 };
3031 let term = run_render(view, 20, 2);
3032
3033 for x in 0u16..6 {
3035 let cell = term.cell((x, 0)).unwrap();
3036 assert!(
3037 !cell.modifier.contains(Modifier::UNDERLINED),
3038 "col {x} must not be underlined (outside overlay)"
3039 );
3040 }
3041 for x in 6u16..11 {
3043 let cell = term.cell((x, 0)).unwrap();
3044 assert!(
3045 cell.modifier.contains(Modifier::UNDERLINED),
3046 "col {x} must be underlined (inside overlay)"
3047 );
3048 }
3049 let cell = term.cell((11, 0)).unwrap();
3051 assert!(
3052 !cell.modifier.contains(Modifier::UNDERLINED),
3053 "col 11 must not be underlined (past overlay end)"
3054 );
3055 }
3056
3057 #[test]
3058 fn diag_overlay_out_of_viewport_is_ignored() {
3059 let b = Buffer::from_str("a\nb\nc");
3061 let v = vp(10, 3);
3062 let overlay = DiagOverlay {
3063 row: 5,
3064 col_start: 0,
3065 col_end: 1,
3066 style: Style::default().add_modifier(Modifier::UNDERLINED),
3067 };
3068 let view = BufferView {
3069 buffer: &b,
3070 viewport: &v,
3071 selection: None,
3072 resolver: &(no_styles as fn(u32) -> Style),
3073 cursor_line_bg: Style::default(),
3074 cursor_column_bg: Style::default(),
3075 selection_bg: Style::default().bg(Color::Blue),
3076 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3077 gutter: None,
3078 search_bg: Style::default(),
3079 signs: &[],
3080 conceals: &[],
3081 spans: &[],
3082 search_pattern: None,
3083 non_text_style: Style::default(),
3084 diag_overlays: &[overlay],
3085 colorcolumn_cols: &[],
3086 colorcolumn_style: Style::default(),
3087 listchars: None,
3088 indent_guides_enabled: false,
3089 indent_guide_char: '│',
3090 indent_guide_shiftwidth: 4,
3091 indent_guide_fg: Color::Reset,
3092 indent_guide_active_fg: Color::Reset,
3093 indent_guide_active_col: None,
3094 fold_line_bg: Style::default(),
3095 eol_hints: &[],
3096 blame_plan: None,
3097 };
3098 let _term = run_render(view, 10, 3);
3100 }
3101
3102 #[test]
3112 fn paint_signs_in_dedicated_column_does_not_overwrite_line_number() {
3113 let b = Buffer::from_str("a\nb");
3117 let v = vp(20, 2);
3119 let sign = Sign {
3120 row: 0,
3121 ch: '~',
3122 style: Style::default().fg(Color::Red),
3123 priority: 10,
3124 };
3125 let view = BufferView {
3126 buffer: &b,
3127 viewport: &v,
3128 selection: None,
3129 resolver: &(no_styles as fn(u32) -> Style),
3130 cursor_line_bg: Style::default(),
3131 cursor_column_bg: Style::default(),
3132 selection_bg: Style::default().bg(Color::Blue),
3133 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3134 gutter: Some(Gutter {
3135 width: 6, style: Style::default(),
3137 line_offset: 13108, sign_column_width: 1,
3139 fold_column_width: 0,
3140 ..Default::default()
3141 }),
3142 search_bg: Style::default(),
3143 signs: &[sign],
3144 conceals: &[],
3145 spans: &[],
3146 search_pattern: None,
3147 non_text_style: Style::default(),
3148 diag_overlays: &[],
3149 colorcolumn_cols: &[],
3150 colorcolumn_style: Style::default(),
3151 listchars: None,
3152 indent_guides_enabled: false,
3153 indent_guide_char: '│',
3154 indent_guide_shiftwidth: 4,
3155 indent_guide_fg: Color::Reset,
3156 indent_guide_active_fg: Color::Reset,
3157 indent_guide_active_col: None,
3158 fold_line_bg: Style::default(),
3159 eol_hints: &[],
3160 blame_plan: None,
3161 };
3162 let term = run_render(view, 20, 2);
3163 assert_eq!(
3165 term.cell((0, 0)).unwrap().symbol(),
3166 "~",
3167 "sign column (x=0) must hold the sign char"
3168 );
3169 assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1", "x=1 must be '1'");
3171 assert_eq!(term.cell((2, 0)).unwrap().symbol(), "3", "x=2 must be '3'");
3172 assert_eq!(term.cell((3, 0)).unwrap().symbol(), "1", "x=3 must be '1'");
3173 assert_eq!(term.cell((4, 0)).unwrap().symbol(), "0", "x=4 must be '0'");
3174 assert_eq!(term.cell((5, 0)).unwrap().symbol(), "9", "x=5 must be '9'");
3175 assert_eq!(
3177 term.cell((6, 0)).unwrap().symbol(),
3178 " ",
3179 "x=6 must be spacer"
3180 );
3181 assert_eq!(
3183 term.cell((7, 0)).unwrap().symbol(),
3184 "a",
3185 "text must start at x=sign_w+num_w=7"
3186 );
3187 }
3188
3189 #[test]
3192 fn paint_signs_zero_sign_column_width_layout_collapses() {
3193 let b = Buffer::from_str("abc");
3194 let v = vp(10, 1);
3195 let sign = Sign {
3196 row: 0,
3197 ch: 'E',
3198 style: Style::default().fg(Color::Red),
3199 priority: 10,
3200 };
3201 let view = BufferView {
3202 buffer: &b,
3203 viewport: &v,
3204 selection: None,
3205 resolver: &(no_styles as fn(u32) -> Style),
3206 cursor_line_bg: Style::default(),
3207 cursor_column_bg: Style::default(),
3208 selection_bg: Style::default().bg(Color::Blue),
3209 cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3210 gutter: Some(Gutter {
3212 width: 3,
3213 style: Style::default(),
3214 line_offset: 0,
3215 sign_column_width: 0,
3216 fold_column_width: 0,
3217 ..Default::default()
3218 }),
3219 search_bg: Style::default(),
3220 signs: &[sign],
3221 conceals: &[],
3222 spans: &[],
3223 search_pattern: None,
3224 non_text_style: Style::default(),
3225 diag_overlays: &[],
3226 colorcolumn_cols: &[],
3227 colorcolumn_style: Style::default(),
3228 listchars: None,
3229 indent_guides_enabled: false,
3230 indent_guide_char: '│',
3231 indent_guide_shiftwidth: 4,
3232 indent_guide_fg: Color::Reset,
3233 indent_guide_active_fg: Color::Reset,
3234 indent_guide_active_col: None,
3235 fold_line_bg: Style::default(),
3236 eol_hints: &[],
3237 blame_plan: None,
3238 };
3239 let term = run_render(view, 10, 1);
3240 assert_ne!(
3242 term.cell((0, 0)).unwrap().symbol(),
3243 "E",
3244 "with sign_column_width=0, sign char must not appear in the gutter"
3245 );
3246 assert_eq!(
3248 term.cell((3, 0)).unwrap().symbol(),
3249 "a",
3250 "text must start at x=gutter.width when sign_column_width=0"
3251 );
3252 }
3253
3254 fn indent_guide_view<'a>(
3258 b: &'a Buffer,
3259 viewport: &'a Viewport,
3260 shiftwidth: usize,
3261 guide_char: char,
3262 guide_fg: Color,
3263 active_fg: Color,
3264 active_col: Option<usize>,
3265 ) -> BufferView<'a, impl StyleResolver + 'a> {
3266 BufferView {
3267 buffer: b,
3268 viewport,
3269 selection: None,
3270 resolver: &(no_styles as fn(u32) -> Style),
3271 cursor_line_bg: Style::default(),
3272 fold_line_bg: Style::default(),
3273 cursor_column_bg: Style::default(),
3274 selection_bg: Style::default(),
3275 cursor_style: Style::default(),
3276 gutter: None,
3277 search_bg: Style::default(),
3278 signs: &[],
3279 conceals: &[],
3280 spans: &[],
3281 search_pattern: None,
3282 non_text_style: Style::default(),
3283 diag_overlays: &[],
3284 colorcolumn_cols: &[],
3285 colorcolumn_style: Style::default(),
3286 listchars: None,
3287 indent_guides_enabled: true,
3288 indent_guide_char: guide_char,
3289 indent_guide_shiftwidth: shiftwidth,
3290 indent_guide_fg: guide_fg,
3291 indent_guide_active_fg: active_fg,
3292 indent_guide_active_col: active_col,
3293 eol_hints: &[],
3294 blame_plan: None,
3295 }
3296 }
3297
3298 #[test]
3299 fn indent_guides_disabled_paints_nothing() {
3300 let b = Buffer::from_str(" foo\n bar");
3302 let v = vp(20, 2);
3303 let view = BufferView {
3304 buffer: &b,
3305 viewport: &v,
3306 selection: None,
3307 resolver: &(no_styles as fn(u32) -> Style),
3308 cursor_line_bg: Style::default(),
3309 cursor_column_bg: Style::default(),
3310 selection_bg: Style::default(),
3311 cursor_style: Style::default(),
3312 gutter: None,
3313 search_bg: Style::default(),
3314 signs: &[],
3315 conceals: &[],
3316 spans: &[],
3317 search_pattern: None,
3318 non_text_style: Style::default(),
3319 diag_overlays: &[],
3320 colorcolumn_cols: &[],
3321 colorcolumn_style: Style::default(),
3322 listchars: None,
3323 indent_guides_enabled: false,
3324 indent_guide_char: '│',
3325 indent_guide_shiftwidth: 4,
3326 indent_guide_fg: Color::DarkGray,
3327 indent_guide_active_fg: Color::Gray,
3328 indent_guide_active_col: None,
3329 fold_line_bg: Style::default(),
3330 eol_hints: &[],
3331 blame_plan: None,
3332 };
3333 let term = run_render(view, 20, 2);
3334 for y in 0..2u16 {
3336 for x in 0..20u16 {
3337 assert_ne!(
3338 term.cell((x, y)).unwrap().symbol(),
3339 "│",
3340 "no guide expected at ({x}, {y}) when disabled"
3341 );
3342 }
3343 }
3344 }
3345
3346 #[test]
3347 fn indent_guides_basic_two_levels() {
3348 let b = Buffer::from_str("fn() {\n if foo {\n bar();\n }");
3351 let v = vp(20, 4);
3352 let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, None);
3354 let term = run_render(view, 20, 4);
3355 assert_ne!(term.cell((4, 0)).unwrap().symbol(), "│");
3357 assert_ne!(
3371 term.cell((4, 1)).unwrap().symbol(),
3372 "│",
3373 "row1: no guide (leading_vcols=4, sw=4, 4<4=false)"
3374 );
3375 assert_eq!(
3376 term.cell((4, 2)).unwrap().symbol(),
3377 "│",
3378 "row2: guide at col 4 (leading_vcols=8)"
3379 );
3380 assert_ne!(
3381 term.cell((8, 2)).unwrap().symbol(),
3382 "│",
3383 "row2: no guide at col 8 (8<8=false)"
3384 );
3385 assert_ne!(
3386 term.cell((4, 3)).unwrap().symbol(),
3387 "│",
3388 "row3: no guide (leading_vcols=4, 4<4=false)"
3389 );
3390 }
3391
3392 #[test]
3393 fn indent_guides_skip_when_no_indent() {
3394 let b = Buffer::from_str("no_indent\nstill_none");
3395 let v = vp(20, 2);
3396 let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, None);
3397 let term = run_render(view, 20, 2);
3398 for y in 0..2u16 {
3399 for x in 0..20u16 {
3400 assert_ne!(
3401 term.cell((x, y)).unwrap().symbol(),
3402 "│",
3403 "no guide expected on non-indented rows"
3404 );
3405 }
3406 }
3407 }
3408
3409 #[test]
3410 fn indent_guides_respects_tabs() {
3411 let b = Buffer::from_str("\t\tfoo");
3415 let mut v = vp(20, 1);
3416 v.tab_width = 4;
3417 let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, None);
3418 let term = run_render(view, 20, 1);
3419 assert_eq!(
3422 term.cell((4, 0)).unwrap().symbol(),
3423 "│",
3424 "guide at visual col 4 inside second tab"
3425 );
3426 assert_eq!(term.cell((8, 0)).unwrap().symbol(), "f");
3428 }
3429
3430 #[test]
3431 fn indent_guides_active_col_uses_active_fg() {
3432 let b = Buffer::from_str(" code");
3435 let v = vp(20, 1);
3436 let active_col = Some(4usize);
3437 let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, active_col);
3438 let term = run_render(view, 20, 1);
3439 let cell = term.cell((4, 0)).unwrap();
3440 assert_eq!(cell.symbol(), "│", "guide painted at col 4");
3441 assert_eq!(cell.fg, Color::Gray, "active col uses active_fg (Gray)");
3442 }
3443
3444 #[test]
3445 fn indent_guides_inactive_col_uses_inactive_fg() {
3446 let b = Buffer::from_str(" code");
3449 let v = vp(20, 1);
3450 let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, Some(8));
3451 let term = run_render(view, 20, 1);
3452 let cell4 = term.cell((4, 0)).unwrap();
3454 assert_eq!(cell4.symbol(), "│", "guide at col 4");
3455 assert_eq!(cell4.fg, Color::DarkGray, "col 4 uses inactive fg");
3456 let cell8 = term.cell((8, 0)).unwrap();
3458 assert_eq!(cell8.symbol(), "│", "guide at col 8");
3459 assert_eq!(cell8.fg, Color::Gray, "col 8 uses active fg");
3460 }
3461
3462 #[test]
3463 fn indent_guides_custom_char_paints_that_char() {
3464 let b = Buffer::from_str(" code");
3466 let v = vp(20, 1);
3467 let view = indent_guide_view(&b, &v, 4, ':', Color::DarkGray, Color::Gray, None);
3468 let term = run_render(view, 20, 1);
3469 assert_eq!(
3470 term.cell((4, 0)).unwrap().symbol(),
3471 ":",
3472 "custom guide char ':' at col 4"
3473 );
3474 }
3475
3476 #[test]
3477 fn eol_hint_paints_after_text() {
3478 let b = Buffer::from_str("ab");
3481 let v = vp(30, 1);
3482 let hint = EolHint {
3483 row: 0,
3484 text: "BLAME".into(),
3485 style: Style::default(),
3486 };
3487 let view = BufferView {
3488 buffer: &b,
3489 viewport: &v,
3490 selection: None,
3491 resolver: &(no_styles as fn(u32) -> Style),
3492 cursor_line_bg: Style::default(),
3493 fold_line_bg: Style::default(),
3494 cursor_column_bg: Style::default(),
3495 selection_bg: Style::default(),
3496 cursor_style: Style::default(),
3497 gutter: None,
3498 search_bg: Style::default(),
3499 signs: &[],
3500 conceals: &[],
3501 spans: &[],
3502 search_pattern: None,
3503 non_text_style: Style::default(),
3504 diag_overlays: &[],
3505 colorcolumn_cols: &[],
3506 colorcolumn_style: Style::default(),
3507 listchars: None,
3508 indent_guides_enabled: false,
3509 indent_guide_char: '│',
3510 indent_guide_shiftwidth: 4,
3511 indent_guide_fg: Color::Reset,
3512 indent_guide_active_fg: Color::Reset,
3513 indent_guide_active_col: None,
3514 eol_hints: &[hint],
3515 blame_plan: None,
3516 };
3517 let term = run_render(view, 30, 1);
3518 let row_text: String = (0..30u16)
3521 .map(|x| {
3522 term.cell((x, 0))
3523 .map(|c| c.symbol().chars().next().unwrap_or(' '))
3524 .unwrap_or(' ')
3525 })
3526 .collect();
3527 assert!(
3528 row_text.contains("BLAME"),
3529 "expected 'BLAME' to appear in row 0, got: {row_text:?}"
3530 );
3531 }
3532
3533 #[test]
3534 fn fold_column_glyph_open_closed_body() {
3535 let mut b = hjkl_buffer::Buffer::from_str("a\nb\nc\nd\ne\nf");
3537 b.add_fold(1, 4, false); b.add_fold(2, 3, true); let folds = b.folds();
3540 assert_eq!(super::fold_column_glyph(&folds, 0), ' ', "row 0: no fold");
3541 assert_eq!(
3542 super::fold_column_glyph(&folds, 1),
3543 '\u{25be}',
3544 "row 1: open fold start = \u{25be}"
3545 );
3546 assert_eq!(
3547 super::fold_column_glyph(&folds, 2),
3548 '\u{25b8}',
3549 "row 2: closed fold start = \u{25b8}"
3550 );
3551 assert_eq!(
3553 super::fold_column_glyph(&folds, 4),
3554 '\u{2502}',
3555 "row 4: open fold body = \u{2502}"
3556 );
3557 assert_eq!(
3558 super::fold_column_glyph(&folds, 5),
3559 ' ',
3560 "row 5: outside all folds"
3561 );
3562 }
3563}