1use super::{
13 ElementKind, ParsedLine, blockquote_gutter, cluster_display_width, cluster_width_at,
14 mask_to_modifier, span_style, tab_width_at,
15};
16use crate::settings::themes::Theme;
17use ratatui::style::Style;
18use ratatui::text::Span;
19use unicode_segmentation::UnicodeSegmentation;
20
21#[cfg(test)]
22use super::{Element, PARSER_OPTIONS, detect::detect_wikilinks, tag_to_kind};
23#[cfg(test)]
24use pulldown_cmark::{Event, Parser, TagEnd};
25
26pub struct MarkdownSpanner;
27
28impl MarkdownSpanner {
29 #[cfg(test)]
30 pub fn parse_elements(line: &str) -> Vec<Element> {
31 let parser = Parser::new_ext(line, PARSER_OPTIONS);
32 let mut elements = Vec::new();
33 let mut stack: Vec<(usize, ElementKind)> = Vec::new();
34 for (event, range) in parser.into_offset_iter() {
35 let sc = line[..range.start].chars().count();
36 let ec = line[..range.end].chars().count();
37 match event {
38 Event::Start(ref tag) if let Some(kind) = tag_to_kind(tag) => {
39 stack.push((sc, kind));
40 }
41 Event::End(
42 TagEnd::Strong
43 | TagEnd::Emphasis
44 | TagEnd::Strikethrough
45 | TagEnd::Link
46 | TagEnd::Heading(_)
47 | TagEnd::BlockQuote(_),
48 ) => {
49 if let Some((s, k)) = stack.pop() {
50 elements.push(Element {
51 start_char: s,
52 end_char: ec,
53 kind: k,
54 });
55 }
56 }
57 Event::Code(_) => elements.push(Element {
58 start_char: sc,
59 end_char: ec,
60 kind: ElementKind::InlineCode,
61 }),
62 _ => {}
63 }
64 }
65 let mut dummy_vis = vec![true; line.chars().count()];
66 detect_wikilinks(line, &mut dummy_vis, &mut elements);
67 elements
68 }
69
70 #[cfg(test)]
73 #[allow(clippy::too_many_arguments)]
74 pub fn render(
75 content: &str,
76 logical_line: &str,
77 visual_start_col: usize,
78 cursor_col: Option<usize>,
79 is_first_visual_line: bool,
80 force_raw: bool,
81 available_width: u16,
82 theme: &Theme,
83 ) -> Vec<Span<'static>> {
84 let parsed = ParsedLine::parse(logical_line);
85 Self::render_with(
86 content,
87 logical_line,
88 &parsed,
89 visual_start_col,
90 cursor_col,
91 is_first_visual_line,
92 force_raw,
93 available_width,
94 theme,
95 )
96 .into_iter()
97 .map(|s| Span::styled(s.content.into_owned(), s.style))
98 .collect()
99 }
100
101 #[cfg(test)]
102 pub fn rendered_cursor_col(
103 logical_line: &str,
104 visual_start_col: usize,
105 cursor_col: usize,
106 is_first_visual_line: bool,
107 force_raw: bool,
108 ) -> usize {
109 let parsed = ParsedLine::parse(logical_line);
110 Self::rendered_cursor_col_with(
111 logical_line,
112 &parsed,
113 visual_start_col,
114 cursor_col,
115 is_first_visual_line,
116 force_raw,
117 )
118 }
119
120 #[cfg(test)]
121 pub fn visible_positions(
122 logical_line: &str,
123 cursor_col: Option<usize>,
124 force_raw: bool,
125 ) -> Vec<bool> {
126 let parsed = ParsedLine::parse(logical_line);
127 Self::visible_positions_with(logical_line, &parsed, cursor_col, force_raw)
128 }
129
130 #[cfg(test)]
131 pub fn rendered_col_to_logical(
132 logical_line: &str,
133 visual_start_col: usize,
134 rendered_col: usize,
135 is_first_visual_line: bool,
136 force_raw: bool,
137 ) -> usize {
138 let parsed = ParsedLine::parse(logical_line);
139 Self::rendered_col_to_logical_with(
140 logical_line,
141 &parsed,
142 visual_start_col,
143 rendered_col,
144 None,
145 is_first_visual_line,
146 force_raw,
147 )
148 }
149
150 #[allow(clippy::too_many_arguments)]
153 pub fn render_with<'a>(
154 content: &'a str,
155 logical_line: &'a str,
156 parsed: &'a ParsedLine,
157 visual_start_col: usize,
158 cursor_col: Option<usize>,
159 is_first_visual_line: bool,
160 force_raw: bool,
161 available_width: u16,
162 theme: &Theme,
163 ) -> Vec<Span<'a>> {
164 let trimmed = logical_line.trim();
166 if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
167 if cursor_col.is_some() {
168 return vec![Span::styled(
169 content,
170 Style::default().fg(theme.gray.to_ratatui()),
171 )];
172 }
173 return vec![Span::styled(
174 "─".repeat(available_width as usize),
175 Style::default().fg(theme.gray.to_ratatui()),
176 )];
177 }
178 if force_raw {
184 let style = Style::default().fg(theme.fg.to_ratatui());
187 if !content.contains('\t') {
188 return vec![Span::styled(content, style)];
189 }
190 let mut expanded = String::with_capacity(content.len());
191 let mut col = 0usize;
192 for cluster in content.graphemes(true) {
193 let w = cluster_width_at(cluster, col);
194 if cluster == "\t" {
195 for _ in 0..w {
196 expanded.push(' ');
197 }
198 } else {
199 expanded.push_str(cluster);
200 }
201 col += w;
202 }
203 return vec![Span::styled(expanded, style)];
204 }
205
206 let bq_gutter: Option<Vec<Span<'a>>> = if cursor_col.is_none() {
211 parsed.blockquote_depth().map(|d| {
212 let style = Style::default().fg(theme.blockquote_bar.to_ratatui());
213 vec![Span::styled(blockquote_gutter(d), style)]
214 })
215 } else {
216 None
217 };
218
219 let elements = &parsed.elements;
220 let content_vis = &parsed.content_vis;
221 let content_char_count = content.chars().count();
222
223 let expanded: Option<usize> = cursor_col.and_then(|c| parsed.elem_at(c));
224 let reveals_whole_row =
226 Self::row_reveals_whole(logical_line, parsed, cursor_col, force_raw);
227
228 let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
236 let list_sigil_end: Option<usize> = parsed.list_sigil_end();
237 let blockquote_sigil_end: Option<usize> = parsed.blockquote_sigil_end();
245
246 let mut spans: Vec<Span<'a>> = Vec::new();
247 let mut seg_str: String = String::new();
248 let mut seg_elem: Option<usize> = None;
249 let mut seg_is_sigil = false;
250 let mut seg_is_expanded = false;
251 let mut seg_mods: u8 = 0;
252 let mut visual_col = 0usize;
254
255 let flush = |seg_str: &mut String,
256 seg_elem: Option<usize>,
257 seg_is_sigil: bool,
258 seg_is_expanded: bool,
259 seg_mods: u8,
260 spans: &mut Vec<Span<'a>>| {
261 if seg_str.is_empty() {
262 return;
263 }
264 let seg = std::mem::take(seg_str);
265 let style = if seg_is_expanded {
266 Style::default().fg(theme.gray.to_ratatui())
267 } else {
268 span_style(seg_elem.map(|i| elements[i].kind), seg_is_sigil, theme)
272 .add_modifier(mask_to_modifier(seg_mods))
273 };
274 spans.push(Span::styled(seg, style));
275 };
276
277 let mut char_pos = visual_start_col;
285 let visual_end_col = visual_start_col + content_char_count;
286 for cluster in content.graphemes(true) {
287 let pos = char_pos;
288 char_pos += cluster.chars().count();
289 if pos >= visual_end_col {
290 break;
291 }
292
293 if let Some(img) = parsed
299 .image_placeholders
300 .iter()
301 .find(|p| p.start_char == pos)
302 {
303 let cursor_in_image = reveals_whole_row
304 || expanded.is_some_and(|i| {
305 elements[i].start_char == img.start_char
306 && elements[i].end_char == img.end_char
307 });
308 if !cursor_in_image {
309 flush(
310 &mut seg_str,
311 seg_elem,
312 seg_is_sigil,
313 seg_is_expanded,
314 seg_mods,
315 &mut spans,
316 );
317 let style = span_style(Some(ElementKind::Image), false, theme);
318 visual_col += img.placeholder_width;
319 spans.push(Span::styled(img.placeholder.as_str(), style));
320 seg_elem = None;
321 seg_is_sigil = false;
322 seg_is_expanded = false;
323 seg_mods = 0;
324 }
325 }
326
327 let is_content = pos < content_vis.len() && content_vis[pos];
328 let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
329 let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
330 let in_blockquote_sigil =
333 bq_gutter.is_none() && blockquote_sigil_end.is_some_and(|end| pos < end);
334 let in_expanded_elem = expanded
335 .is_some_and(|i| elements[i].start_char <= pos && pos < elements[i].end_char);
336 let this_elem = parsed.elem_at(pos);
337 let emit = is_content
338 || in_heading_sigil
339 || in_list_sigil
340 || in_blockquote_sigil
341 || in_expanded_elem
342 || reveals_whole_row
343 || this_elem.is_none();
344 if !emit {
345 flush(
346 &mut seg_str,
347 seg_elem,
348 seg_is_sigil,
349 seg_is_expanded,
350 seg_mods,
351 &mut spans,
352 );
353 seg_elem = None;
354 seg_is_sigil = false;
355 seg_is_expanded = false;
356 seg_mods = 0;
357 continue;
358 }
359 let this_is_expanded = in_expanded_elem || reveals_whole_row;
363 let this_is_sigil = (in_heading_sigil || in_list_sigil || in_blockquote_sigil)
364 && !is_content
365 && !this_is_expanded;
366 let this_mods = parsed.modifiers_at(pos);
367 if this_elem != seg_elem
368 || this_is_sigil != seg_is_sigil
369 || this_is_expanded != seg_is_expanded
370 || this_mods != seg_mods
371 {
372 flush(
373 &mut seg_str,
374 seg_elem,
375 seg_is_sigil,
376 seg_is_expanded,
377 seg_mods,
378 &mut spans,
379 );
380 seg_elem = this_elem;
381 seg_is_sigil = this_is_sigil;
382 seg_is_expanded = this_is_expanded;
383 seg_mods = this_mods;
384 }
385 if cluster == "\t" {
386 let tw = tab_width_at(visual_col);
387 for _ in 0..tw {
388 seg_str.push(' ');
389 }
390 visual_col += tw;
391 } else {
392 seg_str.push_str(cluster);
393 visual_col += cluster_display_width(cluster);
394 }
395 }
396 flush(
397 &mut seg_str,
398 seg_elem,
399 seg_is_sigil,
400 seg_is_expanded,
401 seg_mods,
402 &mut spans,
403 );
404
405 if let Some(mut gutter) = bq_gutter {
407 gutter.extend(spans);
408 spans = gutter;
409 }
410 spans
411 }
412
413 pub fn rendered_cursor_col_with(
423 logical_line: &str,
424 parsed: &ParsedLine,
425 visual_start_col: usize,
426 cursor_col: usize,
427 is_first_visual_line: bool,
428 force_raw: bool,
429 ) -> usize {
430 Self::rendered_col_with_reveal(
431 logical_line,
432 parsed,
433 visual_start_col,
434 cursor_col,
435 Some(cursor_col),
436 is_first_visual_line,
437 force_raw,
438 )
439 }
440
441 #[allow(clippy::too_many_arguments)]
449 pub fn rendered_col_with_reveal(
450 logical_line: &str,
451 parsed: &ParsedLine,
452 visual_start_col: usize,
453 cursor_col: usize,
454 reveal_col: Option<usize>,
455 is_first_visual_line: bool,
456 force_raw: bool,
457 ) -> usize {
458 if force_raw {
459 let mut rendered = 0usize;
462 let mut char_pos = 0usize;
463 for cluster in logical_line.graphemes(true) {
464 if char_pos >= cursor_col {
465 break;
466 }
467 let pos = char_pos;
468 char_pos += cluster.chars().count();
469 if pos < visual_start_col {
470 continue;
471 }
472 rendered += cluster_width_at(cluster, rendered);
473 }
474 return rendered;
475 }
476 let trimmed = logical_line.trim();
477 if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
478 return cursor_col.saturating_sub(visual_start_col);
479 }
480
481 let elements = &parsed.elements;
482 let content_vis = &parsed.content_vis;
483 let logical_char_count = logical_line.chars().count();
484
485 let expanded: Option<usize> = reveal_col.and_then(|c| parsed.elem_at(c));
487 let reveals_whole_row =
490 Self::row_reveals_whole(logical_line, parsed, reveal_col, force_raw);
491 let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
495 let list_sigil_end: Option<usize> = parsed.list_sigil_end();
496 let blockquote_sigil_end: Option<usize> = if is_first_visual_line {
497 parsed.blockquote_sigil_end()
498 } else {
499 None
500 };
501
502 let end = cursor_col.min(logical_char_count);
503 let mut rendered_col = 0usize;
504 let mut char_pos = 0usize;
505 for cluster in logical_line.graphemes(true) {
506 if char_pos >= end {
507 break;
508 }
509 let pos = char_pos;
510 char_pos += cluster.chars().count();
511 if pos < visual_start_col {
512 continue;
513 }
514
515 if let Some(img) = parsed
518 .image_placeholders
519 .iter()
520 .find(|p| p.start_char == pos)
521 {
522 let cursor_in_image = reveals_whole_row
523 || expanded.is_some_and(|i| {
524 elements[i].start_char == img.start_char
525 && elements[i].end_char == img.end_char
526 });
527 if !cursor_in_image {
528 rendered_col += img.placeholder_width;
529 }
530 }
531
532 let is_content = pos < content_vis.len() && content_vis[pos];
533 let in_heading_sigil = heading_sigil_end.is_some_and(|s_end| pos < s_end);
534 let in_list_sigil = list_sigil_end.is_some_and(|s_end| pos < s_end);
535 let in_blockquote_sigil = blockquote_sigil_end.is_some_and(|s_end| pos < s_end);
536 let in_expanded_elem = expanded
537 .is_some_and(|i| elements[i].start_char <= pos && pos < elements[i].end_char);
538 let in_any_element = parsed.in_any_element(pos);
539 let visible = is_content
540 || in_heading_sigil
541 || in_list_sigil
542 || in_blockquote_sigil
543 || in_expanded_elem
544 || reveals_whole_row
545 || !in_any_element;
546 if visible {
547 rendered_col += cluster_width_at(cluster, rendered_col);
548 }
549 }
550 rendered_col
551 }
552
553 pub fn visible_positions_with(
554 logical_line: &str,
555 parsed: &ParsedLine,
556 cursor_col: Option<usize>,
557 force_raw: bool,
558 ) -> Vec<bool> {
559 let mut visible = Self::visible_positions_raw(logical_line, parsed, cursor_col, force_raw);
560 if cursor_col.is_some() && Self::draws_nothing(&visible) {
561 visible.iter_mut().for_each(|v| *v = true);
562 }
563 visible
564 }
565
566 fn draws_nothing(visible: &[bool]) -> bool {
568 !visible.is_empty() && visible.iter().all(|v| !v)
569 }
570
571 fn row_reveals_whole(
585 logical_line: &str,
586 parsed: &ParsedLine,
587 cursor_col: Option<usize>,
588 force_raw: bool,
589 ) -> bool {
590 cursor_col.is_some()
591 && Self::draws_nothing(&Self::visible_positions_raw(
592 logical_line,
593 parsed,
594 cursor_col,
595 force_raw,
596 ))
597 }
598
599 fn visible_positions_raw(
600 logical_line: &str,
601 parsed: &ParsedLine,
602 cursor_col: Option<usize>,
603 force_raw: bool,
604 ) -> Vec<bool> {
605 let total = logical_line.chars().count();
606 if total == 0 {
607 return vec![];
608 }
609 if force_raw {
610 return vec![true; total];
611 }
612 let trimmed = logical_line.trim();
613 if matches!(trimmed, "---" | "***" | "___") {
614 return vec![true; total];
615 }
616
617 let content_vis = &parsed.content_vis;
618 let expanded: Option<usize> = cursor_col.and_then(|c| parsed.elem_at(c));
619 let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
620 let list_sigil_end = parsed.list_sigil_end();
621 let blockquote_sigil_end: Option<usize> = if cursor_col.is_some() {
624 parsed.blockquote_sigil_end()
625 } else {
626 None
627 };
628
629 (0..total)
630 .map(|pos| {
631 let is_content = pos < content_vis.len() && content_vis[pos];
632 let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
633 let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
634 let in_blockquote_sigil = blockquote_sigil_end.is_some_and(|end| pos < end);
635 let in_any_element = parsed.in_any_element(pos);
636 let in_expanded = expanded.is_some_and(|i| {
637 parsed.elements[i].start_char <= pos && pos < parsed.elements[i].end_char
638 });
639 is_content
640 || in_heading_sigil
641 || in_list_sigil
642 || in_blockquote_sigil
643 || in_expanded
644 || !in_any_element
645 })
646 .collect()
647 }
648
649 #[allow(clippy::too_many_arguments)]
659 pub fn rendered_col_to_logical_with(
660 logical_line: &str,
661 parsed: &ParsedLine,
662 visual_start_col: usize,
663 rendered_col: usize,
664 reveal_col: Option<usize>,
665 is_first_visual_line: bool,
666 force_raw: bool,
667 ) -> usize {
668 if force_raw {
669 let mut rendered = 0usize;
672 let mut char_pos = 0usize;
673 for cluster in logical_line.graphemes(true) {
674 let pos = char_pos;
675 if pos < visual_start_col {
676 char_pos += cluster.chars().count();
677 continue;
678 }
679 if rendered >= rendered_col {
680 return pos;
681 }
682 rendered += cluster_width_at(cluster, rendered);
683 char_pos += cluster.chars().count();
684 }
685 return char_pos;
686 }
687 let trimmed = logical_line.trim();
688 if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
689 return visual_start_col + rendered_col;
690 }
691
692 let content_vis = &parsed.content_vis;
693 let logical_char_count = logical_line.chars().count();
694 let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
697 let list_sigil_end: Option<usize> = parsed.list_sigil_end();
698 let expanded: Option<usize> = reveal_col.and_then(|c| parsed.elem_at(c));
700 let reveals_whole_row =
701 Self::row_reveals_whole(logical_line, parsed, reveal_col, force_raw);
702 let blockquote_sigil_end: Option<usize> = if is_first_visual_line {
707 parsed.blockquote_sigil_end()
708 } else {
709 None
710 };
711
712 let mut rendered_count = 0;
713 let mut char_pos = 0usize;
714 for cluster in logical_line.graphemes(true) {
715 let pos = char_pos;
716 char_pos += cluster.chars().count();
717 if pos < visual_start_col {
718 continue;
719 }
720
721 if let Some(img) = parsed
725 .image_placeholders
726 .iter()
727 .find(|p| p.start_char == pos)
728 {
729 let drawn = !reveals_whole_row
734 && !expanded.is_some_and(|i| {
735 parsed.elements[i].start_char == img.start_char
736 && parsed.elements[i].end_char == img.end_char
737 });
738 if drawn {
739 if rendered_count + img.placeholder_width > rendered_col {
740 return pos;
741 }
742 rendered_count += img.placeholder_width;
743 }
744 }
745 let is_content = pos < content_vis.len() && content_vis[pos];
746 let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
747 let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
748 let in_blockquote_sigil = blockquote_sigil_end.is_some_and(|end| pos < end);
749 let in_expanded_elem = expanded.is_some_and(|i| {
750 parsed.elements[i].start_char <= pos && pos < parsed.elements[i].end_char
751 });
752 let in_any_element = parsed.in_any_element(pos);
753 let drawn = is_content
754 || in_heading_sigil
755 || in_list_sigil
756 || in_blockquote_sigil
757 || in_expanded_elem
758 || reveals_whole_row
759 || !in_any_element;
760 if drawn {
767 if rendered_count >= rendered_col {
768 return pos;
769 }
770 rendered_count += cluster_width_at(cluster, rendered_count);
771 }
772 }
773 logical_char_count
774 }
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 #[test]
791 fn mapping_a_column_inside_a_link_does_not_reveal_it() {
792 let line = "[[note]] x";
793 let parsed = ParsedLine::parse(line);
794
795 let collapsed =
798 MarkdownSpanner::rendered_col_with_reveal(line, &parsed, 0, 2, None, true, false);
799 assert_eq!(
800 collapsed, 0,
801 "with the caret away, the hidden `[[` occupies no screen columns"
802 );
803
804 let revealed =
807 MarkdownSpanner::rendered_col_with_reveal(line, &parsed, 0, 2, Some(2), true, false);
808 assert_eq!(revealed, 2, "the caret's own element renders raw");
809
810 assert_eq!(
813 MarkdownSpanner::rendered_cursor_col_with(line, &parsed, 0, 2, true, false),
814 revealed
815 );
816 }
817
818 #[test]
819 fn force_raw_expands_tabs_and_cursor_maps_round_trip() {
820 let theme = crate::settings::themes::Theme::gruvbox_dark();
821 let spans = MarkdownSpanner::render("\tx", "\tx", 0, None, true, true, 40, &theme);
823 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
824 assert_eq!(text, " x", "tab must expand to 4 spaces in force-raw");
825
826 let rc = MarkdownSpanner::rendered_cursor_col("\tx", 0, 1, true, true);
828 assert_eq!(rc, 4);
829 let rc2 = MarkdownSpanner::rendered_cursor_col("\tx", 0, 2, true, true);
831 assert_eq!(rc2, 5);
832
833 assert_eq!(
835 MarkdownSpanner::rendered_col_to_logical("\tx", 0, 4, true, true),
836 1
837 );
838 assert_eq!(
839 MarkdownSpanner::rendered_col_to_logical("\tx", 0, 0, true, true),
840 0
841 );
842 }
843
844 #[test]
845 fn click_maps_over_revealed_blockquote_marker_on_cursor_row() {
846 assert_eq!(
850 MarkdownSpanner::rendered_col_to_logical("> hi", 0, 0, true, false),
851 0
852 );
853 assert_eq!(
854 MarkdownSpanner::rendered_col_to_logical("> hi", 0, 2, true, false),
855 2
856 );
857 }
858
859 #[test]
860 fn blockquote_marker_visible_only_when_cursor_on_line() {
861 let with_cursor = MarkdownSpanner::visible_positions("> hi", Some(2), false);
863 assert_eq!(&with_cursor[0..2], &[true, true]);
864
865 let no_cursor = MarkdownSpanner::visible_positions("> hi", None, false);
867 assert_eq!(&no_cursor[0..2], &[false, false]);
868 }
869
870 #[test]
871 fn blockquote_marker_stays_visible_when_cursor_in_inner_element() {
872 let vis = MarkdownSpanner::visible_positions("> **b**", Some(4), false);
881 assert_eq!(&vis[0..2], &[true, true]);
882 }
883
884 #[test]
885 fn cursor_advances_over_blockquote_marker_on_its_line() {
886 let col = MarkdownSpanner::rendered_cursor_col(
889 "> ", 0, 2, true, false, );
895 assert_eq!(col, 2);
896 }
897
898 #[test]
899 fn blockquote_renders_bar_when_cursor_off_line() {
900 let theme = crate::settings::themes::Theme::gruvbox_dark();
901 let spans = MarkdownSpanner::render("> hi", "> hi", 0, None, true, false, 40, &theme);
903 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
904 assert!(text.starts_with("│ "), "expected bar gutter, got {text:?}");
905 assert!(
906 !text.contains('>'),
907 "raw marker must be hidden, got {text:?}"
908 );
909 assert!(text.contains("hi"));
910 }
911
912 #[test]
913 fn blockquote_reveals_raw_marker_when_cursor_on_line() {
914 let theme = crate::settings::themes::Theme::gruvbox_dark();
915 let spans = MarkdownSpanner::render("> hi", "> hi", 0, Some(2), true, false, 40, &theme);
917 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
918 assert_eq!(text, "> hi");
919 assert!(!text.contains('│'));
920 }
921
922 #[test]
923 fn nested_blockquote_renders_two_bars() {
924 let theme = crate::settings::themes::Theme::gruvbox_dark();
925 let spans = MarkdownSpanner::render(">> x", ">> x", 0, None, true, false, 40, &theme);
926 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
927 assert!(text.starts_with("││ "), "expected two bars, got {text:?}");
928 }
929
930 #[test]
931 fn bare_blockquote_renders_bar_gutter_without_panic() {
932 let theme = crate::settings::themes::Theme::gruvbox_dark();
933 let spans = MarkdownSpanner::render(">", ">", 0, None, true, false, 40, &theme);
934 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
935 assert!(text.starts_with("│ "), "expected bar gutter, got {text:?}");
936 assert!(
937 !text.contains('>'),
938 "raw marker must be hidden, got {text:?}"
939 );
940 }
941}