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 is_first_visual_line,
145 force_raw,
146 )
147 }
148
149 #[allow(clippy::too_many_arguments)]
152 pub fn render_with<'a>(
153 content: &'a str,
154 logical_line: &'a str,
155 parsed: &'a ParsedLine,
156 visual_start_col: usize,
157 cursor_col: Option<usize>,
158 is_first_visual_line: bool,
159 force_raw: bool,
160 available_width: u16,
161 theme: &Theme,
162 ) -> Vec<Span<'a>> {
163 let trimmed = logical_line.trim();
165 if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
166 if cursor_col.is_some() {
167 return vec![Span::styled(
168 content,
169 Style::default().fg(theme.gray.to_ratatui()),
170 )];
171 }
172 return vec![Span::styled(
173 "─".repeat(available_width as usize),
174 Style::default().fg(theme.gray.to_ratatui()),
175 )];
176 }
177 if force_raw {
183 let style = Style::default().fg(theme.fg.to_ratatui());
186 if !content.contains('\t') {
187 return vec![Span::styled(content, style)];
188 }
189 let mut expanded = String::with_capacity(content.len());
190 let mut col = 0usize;
191 for cluster in content.graphemes(true) {
192 let w = cluster_width_at(cluster, col);
193 if cluster == "\t" {
194 for _ in 0..w {
195 expanded.push(' ');
196 }
197 } else {
198 expanded.push_str(cluster);
199 }
200 col += w;
201 }
202 return vec![Span::styled(expanded, style)];
203 }
204
205 let bq_gutter: Option<Vec<Span<'a>>> = if cursor_col.is_none() {
210 parsed.blockquote_depth().map(|d| {
211 let style = Style::default().fg(theme.blockquote_bar.to_ratatui());
212 vec![Span::styled(blockquote_gutter(d), style)]
213 })
214 } else {
215 None
216 };
217
218 let elements = &parsed.elements;
219 let content_vis = &parsed.content_vis;
220 let content_char_count = content.chars().count();
221
222 let expanded: Option<usize> = cursor_col.and_then(|c| parsed.elem_at(c));
223
224 let heading_sigil_end: Option<usize> = if is_first_visual_line {
225 parsed.heading_sigil_end()
226 } else {
227 None
228 };
229 let list_sigil_end: Option<usize> = if is_first_visual_line {
230 parsed.list_sigil_end()
231 } else {
232 None
233 };
234 let blockquote_sigil_end: Option<usize> = if is_first_visual_line {
235 parsed.blockquote_sigil_end()
236 } else {
237 None
238 };
239
240 let mut spans: Vec<Span<'a>> = Vec::new();
241 let mut seg_str: String = String::new();
242 let mut seg_elem: Option<usize> = None;
243 let mut seg_is_sigil = false;
244 let mut seg_is_expanded = false;
245 let mut seg_mods: u8 = 0;
246 let mut visual_col = 0usize;
248
249 let flush = |seg_str: &mut String,
250 seg_elem: Option<usize>,
251 seg_is_sigil: bool,
252 seg_is_expanded: bool,
253 seg_mods: u8,
254 spans: &mut Vec<Span<'a>>| {
255 if seg_str.is_empty() {
256 return;
257 }
258 let seg = std::mem::take(seg_str);
259 let style = if seg_is_expanded {
260 Style::default().fg(theme.gray.to_ratatui())
261 } else {
262 span_style(seg_elem.map(|i| elements[i].kind), seg_is_sigil, theme)
266 .add_modifier(mask_to_modifier(seg_mods))
267 };
268 spans.push(Span::styled(seg, style));
269 };
270
271 let mut char_pos = visual_start_col;
279 let visual_end_col = visual_start_col + content_char_count;
280 for cluster in content.graphemes(true) {
281 let pos = char_pos;
282 char_pos += cluster.chars().count();
283 if pos >= visual_end_col {
284 break;
285 }
286
287 if let Some(img) = parsed
293 .image_placeholders
294 .iter()
295 .find(|p| p.start_char == pos)
296 {
297 let cursor_in_image = expanded.is_some_and(|i| {
298 elements[i].start_char == img.start_char && elements[i].end_char == img.end_char
299 });
300 if !cursor_in_image {
301 flush(
302 &mut seg_str,
303 seg_elem,
304 seg_is_sigil,
305 seg_is_expanded,
306 seg_mods,
307 &mut spans,
308 );
309 let style = span_style(Some(ElementKind::Image), false, theme);
310 visual_col += img.placeholder_width;
311 spans.push(Span::styled(img.placeholder.as_str(), style));
312 seg_elem = None;
313 seg_is_sigil = false;
314 seg_is_expanded = false;
315 seg_mods = 0;
316 }
317 }
318
319 let is_content = pos < content_vis.len() && content_vis[pos];
320 let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
321 let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
322 let in_blockquote_sigil =
325 bq_gutter.is_none() && blockquote_sigil_end.is_some_and(|end| pos < end);
326 let in_expanded_elem = expanded
327 .is_some_and(|i| elements[i].start_char <= pos && pos < elements[i].end_char);
328 let this_elem = parsed.elem_at(pos);
329 let emit = is_content
330 || in_heading_sigil
331 || in_list_sigil
332 || in_blockquote_sigil
333 || in_expanded_elem
334 || this_elem.is_none();
335 if !emit {
336 flush(
337 &mut seg_str,
338 seg_elem,
339 seg_is_sigil,
340 seg_is_expanded,
341 seg_mods,
342 &mut spans,
343 );
344 seg_elem = None;
345 seg_is_sigil = false;
346 seg_is_expanded = false;
347 seg_mods = 0;
348 continue;
349 }
350 let this_is_expanded = in_expanded_elem;
351 let this_is_sigil = (in_heading_sigil || in_list_sigil || in_blockquote_sigil)
352 && !is_content
353 && !in_expanded_elem;
354 let this_mods = parsed.modifiers_at(pos);
355 if this_elem != seg_elem
356 || this_is_sigil != seg_is_sigil
357 || this_is_expanded != seg_is_expanded
358 || this_mods != seg_mods
359 {
360 flush(
361 &mut seg_str,
362 seg_elem,
363 seg_is_sigil,
364 seg_is_expanded,
365 seg_mods,
366 &mut spans,
367 );
368 seg_elem = this_elem;
369 seg_is_sigil = this_is_sigil;
370 seg_is_expanded = this_is_expanded;
371 seg_mods = this_mods;
372 }
373 if cluster == "\t" {
374 let tw = tab_width_at(visual_col);
375 for _ in 0..tw {
376 seg_str.push(' ');
377 }
378 visual_col += tw;
379 } else {
380 seg_str.push_str(cluster);
381 visual_col += cluster_display_width(cluster);
382 }
383 }
384 flush(
385 &mut seg_str,
386 seg_elem,
387 seg_is_sigil,
388 seg_is_expanded,
389 seg_mods,
390 &mut spans,
391 );
392
393 if spans.is_empty() && bq_gutter.is_none() {
397 spans.push(Span::styled(
398 content,
399 Style::default().fg(theme.fg.to_ratatui()),
400 ));
401 }
402 if let Some(mut gutter) = bq_gutter {
406 gutter.extend(spans);
407 spans = gutter;
408 }
409 spans
410 }
411
412 pub fn rendered_cursor_col_with(
413 logical_line: &str,
414 parsed: &ParsedLine,
415 visual_start_col: usize,
416 cursor_col: usize,
417 is_first_visual_line: bool,
418 force_raw: bool,
419 ) -> usize {
420 if force_raw {
421 let mut rendered = 0usize;
424 let mut char_pos = 0usize;
425 for cluster in logical_line.graphemes(true) {
426 if char_pos >= cursor_col {
427 break;
428 }
429 let pos = char_pos;
430 char_pos += cluster.chars().count();
431 if pos < visual_start_col {
432 continue;
433 }
434 rendered += cluster_width_at(cluster, rendered);
435 }
436 return rendered;
437 }
438 let trimmed = logical_line.trim();
439 if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
440 return cursor_col.saturating_sub(visual_start_col);
441 }
442
443 let elements = &parsed.elements;
444 let content_vis = &parsed.content_vis;
445 let logical_char_count = logical_line.chars().count();
446
447 let expanded: Option<usize> = parsed.elem_at(cursor_col);
448 let heading_sigil_end: Option<usize> = if is_first_visual_line {
449 parsed.heading_sigil_end()
450 } else {
451 None
452 };
453 let list_sigil_end: Option<usize> = if is_first_visual_line {
454 parsed.list_sigil_end()
455 } else {
456 None
457 };
458 let blockquote_sigil_end: Option<usize> = if is_first_visual_line {
459 parsed.blockquote_sigil_end()
460 } else {
461 None
462 };
463
464 let end = cursor_col.min(logical_char_count);
465 let mut rendered_col = 0usize;
466 let mut char_pos = 0usize;
467 for cluster in logical_line.graphemes(true) {
468 if char_pos >= end {
469 break;
470 }
471 let pos = char_pos;
472 char_pos += cluster.chars().count();
473 if pos < visual_start_col {
474 continue;
475 }
476
477 if let Some(img) = parsed
480 .image_placeholders
481 .iter()
482 .find(|p| p.start_char == pos)
483 {
484 let cursor_in_image = expanded.is_some_and(|i| {
485 elements[i].start_char == img.start_char && elements[i].end_char == img.end_char
486 });
487 if !cursor_in_image {
488 rendered_col += img.placeholder_width;
489 }
490 }
491
492 let is_content = pos < content_vis.len() && content_vis[pos];
493 let in_heading_sigil = heading_sigil_end.is_some_and(|s_end| pos < s_end);
494 let in_list_sigil = list_sigil_end.is_some_and(|s_end| pos < s_end);
495 let in_blockquote_sigil = blockquote_sigil_end.is_some_and(|s_end| pos < s_end);
496 let in_expanded_elem = expanded
497 .is_some_and(|i| elements[i].start_char <= pos && pos < elements[i].end_char);
498 let in_any_element = parsed.in_any_element(pos);
499 let visible = is_content
500 || in_heading_sigil
501 || in_list_sigil
502 || in_blockquote_sigil
503 || in_expanded_elem
504 || !in_any_element;
505 if visible {
506 rendered_col += cluster_width_at(cluster, rendered_col);
507 }
508 }
509 rendered_col
510 }
511
512 pub fn visible_positions_with(
513 logical_line: &str,
514 parsed: &ParsedLine,
515 cursor_col: Option<usize>,
516 force_raw: bool,
517 ) -> Vec<bool> {
518 let total = logical_line.chars().count();
519 if total == 0 {
520 return vec![];
521 }
522 if force_raw {
523 return vec![true; total];
524 }
525 let trimmed = logical_line.trim();
526 if matches!(trimmed, "---" | "***" | "___") {
527 return vec![true; total];
528 }
529
530 let content_vis = &parsed.content_vis;
531 let expanded: Option<usize> = cursor_col.and_then(|c| parsed.elem_at(c));
532 let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
533 let list_sigil_end = parsed.list_sigil_end();
534 let blockquote_sigil_end: Option<usize> = if cursor_col.is_some() {
537 parsed.blockquote_sigil_end()
538 } else {
539 None
540 };
541
542 (0..total)
543 .map(|pos| {
544 let is_content = pos < content_vis.len() && content_vis[pos];
545 let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
546 let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
547 let in_blockquote_sigil = blockquote_sigil_end.is_some_and(|end| pos < end);
548 let in_any_element = parsed.in_any_element(pos);
549 let in_expanded = expanded.is_some_and(|i| {
550 parsed.elements[i].start_char <= pos && pos < parsed.elements[i].end_char
551 });
552 is_content
553 || in_heading_sigil
554 || in_list_sigil
555 || in_blockquote_sigil
556 || in_expanded
557 || !in_any_element
558 })
559 .collect()
560 }
561
562 pub fn rendered_col_to_logical_with(
563 logical_line: &str,
564 parsed: &ParsedLine,
565 visual_start_col: usize,
566 rendered_col: usize,
567 is_first_visual_line: bool,
568 force_raw: bool,
569 ) -> usize {
570 if force_raw {
571 let mut rendered = 0usize;
574 let mut char_pos = 0usize;
575 for cluster in logical_line.graphemes(true) {
576 let pos = char_pos;
577 if pos < visual_start_col {
578 char_pos += cluster.chars().count();
579 continue;
580 }
581 if rendered >= rendered_col {
582 return pos;
583 }
584 rendered += cluster_width_at(cluster, rendered);
585 char_pos += cluster.chars().count();
586 }
587 return char_pos;
588 }
589 let trimmed = logical_line.trim();
590 if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
591 return visual_start_col + rendered_col;
592 }
593
594 let content_vis = &parsed.content_vis;
595 let logical_char_count = logical_line.chars().count();
596 let heading_sigil_end: Option<usize> = if is_first_visual_line {
597 parsed.heading_sigil_end()
598 } else {
599 None
600 };
601 let list_sigil_end: Option<usize> = if is_first_visual_line {
602 parsed.list_sigil_end()
603 } else {
604 None
605 };
606 let blockquote_sigil_end: Option<usize> = if is_first_visual_line {
611 parsed.blockquote_sigil_end()
612 } else {
613 None
614 };
615
616 let mut rendered_count = 0;
617 let mut char_pos = 0usize;
618 for cluster in logical_line.graphemes(true) {
619 let pos = char_pos;
620 char_pos += cluster.chars().count();
621 if pos < visual_start_col {
622 continue;
623 }
624
625 if rendered_count >= rendered_col {
626 return pos;
627 }
628 if let Some(img) = parsed
632 .image_placeholders
633 .iter()
634 .find(|p| p.start_char == pos)
635 {
636 if rendered_count + img.placeholder_width > rendered_col {
637 return pos;
638 }
639 rendered_count += img.placeholder_width;
640 }
641 let is_content = pos < content_vis.len() && content_vis[pos];
642 let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
643 let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
644 let in_blockquote_sigil = blockquote_sigil_end.is_some_and(|end| pos < end);
645 let in_any_element = parsed.in_any_element(pos);
646 if is_content
647 || in_heading_sigil
648 || in_list_sigil
649 || in_blockquote_sigil
650 || !in_any_element
651 {
652 rendered_count += cluster_width_at(cluster, rendered_count);
653 }
654 }
655 logical_char_count
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn force_raw_expands_tabs_and_cursor_maps_round_trip() {
665 let theme = crate::settings::themes::Theme::gruvbox_dark();
666 let spans = MarkdownSpanner::render("\tx", "\tx", 0, None, true, true, 40, &theme);
668 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
669 assert_eq!(text, " x", "tab must expand to 4 spaces in force-raw");
670
671 let rc = MarkdownSpanner::rendered_cursor_col("\tx", 0, 1, true, true);
673 assert_eq!(rc, 4);
674 let rc2 = MarkdownSpanner::rendered_cursor_col("\tx", 0, 2, true, true);
676 assert_eq!(rc2, 5);
677
678 assert_eq!(
680 MarkdownSpanner::rendered_col_to_logical("\tx", 0, 4, true, true),
681 1
682 );
683 assert_eq!(
684 MarkdownSpanner::rendered_col_to_logical("\tx", 0, 0, true, true),
685 0
686 );
687 }
688
689 #[test]
690 fn click_maps_over_revealed_blockquote_marker_on_cursor_row() {
691 assert_eq!(
695 MarkdownSpanner::rendered_col_to_logical("> hi", 0, 0, true, false),
696 0
697 );
698 assert_eq!(
699 MarkdownSpanner::rendered_col_to_logical("> hi", 0, 2, true, false),
700 2
701 );
702 }
703
704 #[test]
705 fn blockquote_marker_visible_only_when_cursor_on_line() {
706 let with_cursor = MarkdownSpanner::visible_positions("> hi", Some(2), false);
708 assert_eq!(&with_cursor[0..2], &[true, true]);
709
710 let no_cursor = MarkdownSpanner::visible_positions("> hi", None, false);
712 assert_eq!(&no_cursor[0..2], &[false, false]);
713 }
714
715 #[test]
716 fn blockquote_marker_stays_visible_when_cursor_in_inner_element() {
717 let vis = MarkdownSpanner::visible_positions("> **b**", Some(4), false);
726 assert_eq!(&vis[0..2], &[true, true]);
727 }
728
729 #[test]
730 fn cursor_advances_over_blockquote_marker_on_its_line() {
731 let col = MarkdownSpanner::rendered_cursor_col(
734 "> ", 0, 2, true, false, );
740 assert_eq!(col, 2);
741 }
742
743 #[test]
744 fn blockquote_renders_bar_when_cursor_off_line() {
745 let theme = crate::settings::themes::Theme::gruvbox_dark();
746 let spans = MarkdownSpanner::render("> hi", "> hi", 0, None, true, false, 40, &theme);
748 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
749 assert!(text.starts_with("│ "), "expected bar gutter, got {text:?}");
750 assert!(
751 !text.contains('>'),
752 "raw marker must be hidden, got {text:?}"
753 );
754 assert!(text.contains("hi"));
755 }
756
757 #[test]
758 fn blockquote_reveals_raw_marker_when_cursor_on_line() {
759 let theme = crate::settings::themes::Theme::gruvbox_dark();
760 let spans = MarkdownSpanner::render("> hi", "> hi", 0, Some(2), true, false, 40, &theme);
762 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
763 assert_eq!(text, "> hi");
764 assert!(!text.contains('│'));
765 }
766
767 #[test]
768 fn nested_blockquote_renders_two_bars() {
769 let theme = crate::settings::themes::Theme::gruvbox_dark();
770 let spans = MarkdownSpanner::render(">> x", ">> x", 0, None, true, false, 40, &theme);
771 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
772 assert!(text.starts_with("││ "), "expected two bars, got {text:?}");
773 }
774
775 #[test]
776 fn bare_blockquote_renders_bar_gutter_without_panic() {
777 let theme = crate::settings::themes::Theme::gruvbox_dark();
778 let spans = MarkdownSpanner::render(">", ">", 0, None, true, false, 40, &theme);
779 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
780 assert!(text.starts_with("│ "), "expected bar gutter, got {text:?}");
781 assert!(
782 !text.contains('>'),
783 "raw marker must be hidden, got {text:?}"
784 );
785 }
786}