Skip to main content

kimun_notes/components/text_editor/markdown/
spanner.rs

1//! Span rendering for a single visual line.
2//!
3//! Given a logical line (the source markdown), a pre-parsed
4//! [`ParsedLine`] (sigils, elements, image placeholders), and the
5//! visual slice the editor wants to render, [`MarkdownSpanner`]
6//! emits a vector of styled ratatui [`Span`]s with the right
7//! fg/bg/modifier per element kind and the right
8//! sigil-collapse/cursor-expand UX. Also exposes inverse mappings
9//! (cursor-col, click-col → logical char index) used by the editor
10//! to keep the cursor in sync after wrapping.
11
12use 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    // ── Public API (parse-on-the-fly wrappers, used in tests only) ───────────
71
72    #[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    // ── `_with` variants: accept pre-parsed `&ParsedLine` ────────────────────
150
151    #[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        // HR
164        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        // Force-raw (inside fenced code block). Expand tabs to spaces (at the
178        // editor's TAB_STOP) so the rendered width is deterministic — matching
179        // `raw_display_width` (used to size the code box) and the non-force-raw
180        // tab handling, instead of emitting a literal tab whose width the
181        // terminal decides. The no-tab fast path borrows `content` (no alloc).
182        if force_raw {
183            // Use `fg` (the primary text color) so fenced code text matches
184            // indented code, which renders through the plain-text path (`fg`).
185            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        // Blockquote gutter: when the cursor is off this line, draw a `│` bar
206        // per nesting depth (in `blockquote_bar`) in place of the hidden `>`
207        // markers, on EVERY visual row. When the cursor IS on the line the
208        // markers are revealed raw instead (handled by the sigil path below).
209        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        // Tracks the current rendered visual column for tab-stop calculation.
247        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                // OR in emphasis modifiers from outer elements so a Link /
263                // WikiLink nested in `**…**` / `*…*` keeps the bold/italic the
264                // innermost-element style alone would drop.
265                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        // Iterate the visual-line slice rather than walking the whole logical
272        // line and skipping clusters before `visual_start_col`. For a paragraph
273        // wrapped across N visual rows this used to scan the full logical line
274        // N times per frame; now each row's iteration is bounded to its own
275        // slice. `char_pos` is seeded with `visual_start_col` so positions
276        // continue to index into `content_vis`, `elements`, and the image
277        // placeholders, which are all addressed in logical-line coordinates.
278        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            // Image placeholder: at the start of an `![..](..)` range, emit a
288            // single styled placeholder span and let the existing emit logic
289            // skip the underlying chars (they have content_vis=false). When the
290            // cursor sits inside the image element we fall through and render
291            // the raw markdown instead, matching the "expanded element" UX.
292            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            // Only reveal the raw `> ` markers when there is no gutter, i.e.
323            // when the cursor is on this line.
324            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        // Empty-content fallback. Skipped when a blockquote gutter will be
394        // prepended, otherwise a bare `>` line would re-emit its hidden raw
395        // marker on top of the gutter.
396        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        // Prepend the blockquote bar gutter (cursor-off-line case). Placed after
403        // the empty-fallback so a bare `>` line still gets its gutter without
404        // panicking.
405        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            // Tab-aware: code is rendered with tabs expanded to TAB_STOP, so the
422            // rendered cursor column must sum expanded widths, not char counts.
423            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            // Account for placeholder width when crossing the start of an image
478            // span — kept consistent with `render_with`'s placeholder injection.
479            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        // Reveal the blockquote marker only while the cursor is on this line;
535        // otherwise it stays hidden and the view draws the `│` gutter instead.
536        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            // Tab-aware inverse of `rendered_cursor_col_with`'s force-raw branch:
572            // walk expanded widths to find the logical char at `rendered_col`.
573            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        // Mirror `rendered_cursor_col_with`: on the first visual line a
607        // blockquote's `> ` markers are revealed (visible) when the cursor is on
608        // the row. On non-cursor rows the caller passes `visual_start_col` past
609        // the markers (the gutter case), so this clause is inert there.
610        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            // A click landing inside the placeholder region maps back to the
629            // start of the image span (the only logical position that visually
630            // corresponds to the placeholder).
631            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        // "\tx" force-raw: tab at col 0 → TAB_STOP (4) spaces, then 'x' → 5 cols.
667        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        // Cursor after the tab (logical col 1) is at rendered col 4 (tab width).
672        let rc = MarkdownSpanner::rendered_cursor_col("\tx", 0, 1, true, true);
673        assert_eq!(rc, 4);
674        // Cursor after 'x' (logical col 2) is at rendered col 5.
675        let rc2 = MarkdownSpanner::rendered_cursor_col("\tx", 0, 2, true, true);
676        assert_eq!(rc2, 5);
677
678        // Inverse: rendered col 4 maps back to logical col 1 ('x'); col 0 → 0.
679        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        // Cursor-row blockquote (markers revealed, no gutter): rendered_col 0/1
692        // map to the '>' and ' ' (logical 0/1), rendered_col 2 to 'h' (logical 2)
693        // — not skipped as hidden.
694        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        // Cursor on the line → "> " revealed (both chars visible).
707        let with_cursor = MarkdownSpanner::visible_positions("> hi", Some(2), false);
708        assert_eq!(&with_cursor[0..2], &[true, true]);
709
710        // Cursor off the line → "> " hidden (gutter draws the bar instead).
711        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        // Cursor (col 4) sits inside the bold span of "> **b**". elem_at resolves
718        // to the Bold element (start_char=2, end_char=7), not the line-spanning
719        // Blockquote, so only the new blockquote-sigil reveal keeps the "> "
720        // marker (cols 0,1) visible.
721        //
722        // Parsed: Blockquote [0,7), Bold [2,7); blockquote_sigil_end = Some(4).
723        // Without in_blockquote_sigil: cols 0,1 are in_any_element=true but
724        // in_expanded=false → hidden. With it: pos < 4 → visible.
725        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        // Cursor just after "> " on a bare blockquote line. Rendered column must
732        // be 2 (the "> " is revealed and visible on the cursor's own line), not 0.
733        let col = MarkdownSpanner::rendered_cursor_col(
734            "> ",  // logical line
735            0,     // visual_start_col
736            2,     // cursor_col (end of line)
737            true,  // is_first_visual_line
738            false, // force_raw
739        );
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        // cursor_col = None → bar gutter, raw "> " hidden.
747        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        // cursor_col = Some(..) → raw "> hi" shown, no bar.
761        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}