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            None,
145            is_first_visual_line,
146            force_raw,
147        )
148    }
149
150    // ── `_with` variants: accept pre-parsed `&ParsedLine` ────────────────────
151
152    #[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        // HR
165        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        // Force-raw (inside fenced code block). Expand tabs to spaces (at the
179        // editor's TAB_STOP) so the rendered width is deterministic — matching
180        // `raw_display_width` (used to size the code box) and the non-force-raw
181        // tab handling, instead of emitting a literal tab whose width the
182        // terminal decides. The no-tab fast path borrows `content` (no alloc).
183        if force_raw {
184            // Use `fg` (the primary text color) so fenced code text matches
185            // indented code, which renders through the plain-text path (`fg`).
186            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        // Blockquote gutter: when the cursor is off this line, draw a `│` bar
207        // per nesting depth (in `blockquote_bar`) in place of the hidden `>`
208        // markers, on EVERY visual row. When the cursor IS on the line the
209        // markers are revealed raw instead (handled by the sigil path below).
210        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        // The caret's row is never left invisible: see `row_reveals_whole`.
225        let reveals_whole_row =
226            Self::row_reveals_whole(logical_line, parsed, cursor_col, force_raw);
227
228        // Ungated on the visual row, matching `visible_positions_with` — which
229        // is the wrap mask, and so decides how many cells each row reserves. A
230        // sigil region can outrun the pane (a setext underline, a heading whose
231        // `#` run is the whole row), and the mask reserves those cells on the
232        // continuation row. Gating here left that row with no spans at all.
233        // Inert for an ordinary `# Title` / `- item`, whose `visual_start_col`
234        // is already past the sigil on every row but the first.
235        let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
236        let list_sigil_end: Option<usize> = parsed.list_sigil_end();
237        // Ungated too, and for the same reason — the reveal window is not the
238        // `>` run: `blockquote_sigil_end` is the element's first content char,
239        // which is the *whole row* when the quote holds no `Event::Text` (an
240        // HTML block inside a quote), so it outruns the pane readily. The
241        // cursor-vs-gutter distinction that does the real work here is
242        // `bq_gutter.is_none()` at the emit site below, which makes this
243        // predicate identical to the mask's `cursor_col.is_some()` gate.
244        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        // Tracks the current rendered visual column for tab-stop calculation.
253        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                // OR in emphasis modifiers from outer elements so a Link /
269                // WikiLink nested in `**…**` / `*…*` keeps the bold/italic the
270                // innermost-element style alone would drop.
271                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        // Iterate the visual-line slice rather than walking the whole logical
278        // line and skipping clusters before `visual_start_col`. For a paragraph
279        // wrapped across N visual rows this used to scan the full logical line
280        // N times per frame; now each row's iteration is bounded to its own
281        // slice. `char_pos` is seeded with `visual_start_col` so positions
282        // continue to index into `content_vis`, `elements`, and the image
283        // placeholders, which are all addressed in logical-line coordinates.
284        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            // Image placeholder: at the start of an `![..](..)` range, emit a
294            // single styled placeholder span and let the existing emit logic
295            // skip the underlying chars (they have content_vis=false). When the
296            // cursor sits inside the image element we fall through and render
297            // the raw markdown instead, matching the "expanded element" UX.
298            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            // Only reveal the raw `> ` markers when there is no gutter, i.e.
331            // when the cursor is on this line.
332            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            // A row revealing in full is styled as an expanded element would
360            // be — muted, so it reads as raw source under the caret rather than
361            // as a live link the reader could follow.
362            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        // Prepend the blockquote bar gutter (cursor-off-line case).
406        if let Some(mut gutter) = bq_gutter {
407            gutter.extend(spans);
408            spans = gutter;
409        }
410        spans
411    }
412
413    /// Rendered screen column for `cursor_col`, treating that same column as
414    /// the caret — so the markdown element it lands in counts as revealed.
415    ///
416    /// Correct for mapping the real caret. To map an arbitrary column (a
417    /// selection edge, a **replace preview** span boundary) use
418    /// [`Self::rendered_col_with_reveal`] and pass the caret separately:
419    /// otherwise the mapper reveals whatever element the *boundary* touches,
420    /// counts that element's hidden sigils as drawn, and the highlight lands
421    /// right of the text it belongs to.
422    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    /// Rendered screen column for `target_col`, with element reveal driven by
442    /// `reveal_col` — the row's real caret column, or `None` when the caret is
443    /// on another row.
444    ///
445    /// The split matters because `render_with` reveals only the element under
446    /// the *caret*. Any mapping that assumed the measured column was the caret
447    /// would disagree with what was actually drawn.
448    #[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            // Tab-aware: code is rendered with tabs expanded to TAB_STOP, so the
460            // rendered cursor column must sum expanded widths, not char counts.
461            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        // Reveal follows the caret, never the column being measured.
486        let expanded: Option<usize> = reveal_col.and_then(|c| parsed.elem_at(c));
487        // Must match `render_with`, or the caret draws in the wrong cell on a
488        // row that reveals in full.
489        let reveals_whole_row =
490            Self::row_reveals_whole(logical_line, parsed, reveal_col, force_raw);
491        // Ungated on the visual row, as in `render_with`: a sigil region that
492        // outruns the pane draws on its continuation row, so a column there
493        // must measure it rather than collapsing to zero.
494        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            // Account for placeholder width when crossing the start of an image
516            // span — kept consistent with `render_with`'s placeholder injection.
517            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    /// Whether a row's visibility mask would leave it entirely unpainted.
567    fn draws_nothing(visible: &[bool]) -> bool {
568        !visible.is_empty() && visible.iter().all(|v| !v)
569    }
570
571    /// Whether the caret's own row reveals in full rather than element-wise.
572    ///
573    /// **Reveal** is scoped to the element under the caret, and at end of line
574    /// there is no element under the caret — `elem_at` is half-open — so a row
575    /// that is *entirely* concealed markdown (`[](url)`, `**<br/>**`) reveals
576    /// nothing and draws nothing, while the caret sits on it. A row the caret
577    /// is on must never be invisible, so the whole row reveals instead.
578    ///
579    /// A fact about the logical row, not about a visual slice of it, so both
580    /// the wrap mask and the renderer derive it from the same predicate and
581    /// cannot disagree. The empty-content fallback this replaces was keyed on
582    /// the *slice* — which is decided by the wrap that the mask feeds, and so
583    /// could never be stated as a hint.
584    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        // Reveal the blockquote marker only while the cursor is on this line;
622        // otherwise it stays hidden and the view draws the `│` gutter instead.
623        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    /// Logical column for the cell at `rendered_col`, inverse of
650    /// [`Self::rendered_col_with_reveal`].
651    ///
652    /// `reveal_col` is the row's real caret column, or `None` when the caret is
653    /// on another row — the same value the render loop passes as `cursor_col`.
654    /// It has to be threaded here because `render_with` reveals the element
655    /// under the caret, and a revealed element's sigils occupy cells: measuring
656    /// the row as if nothing were revealed put every column past the first
657    /// revealed sigil out by the width of what was wrongly skipped.
658    #[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            // Tab-aware inverse of `rendered_cursor_col_with`'s force-raw branch:
670            // walk expanded widths to find the logical char at `rendered_col`.
671            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        // Ungated on the visual row, as in `render_with`: the inverse mapping
695        // has to land inside a sigil region that outran the pane, not past it.
696        let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
697        let list_sigil_end: Option<usize> = parsed.list_sigil_end();
698        // Reveal, exactly as `render_with` applies it.
699        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        // Mirror `rendered_cursor_col_with`: on the first visual line a
703        // blockquote's `> ` markers are revealed (visible) when the cursor is on
704        // the row. On non-cursor rows the caller passes `visual_start_col` past
705        // the markers (the gutter case), so this clause is inert there.
706        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            // A click landing inside the placeholder region maps back to the
722            // start of the image span (the only logical position that visually
723            // corresponds to the placeholder).
724            if let Some(img) = parsed
725                .image_placeholders
726                .iter()
727                .find(|p| p.start_char == pos)
728            {
729                // Only when a placeholder is actually drawn. A revealed image —
730                // the caret inside it, or the whole row revealing — shows its raw
731                // markdown instead, and counting a placeholder that is not on
732                // screen walks this mapping past every column after it.
733                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            // An undrawn column belongs to the drawn column that follows it, so
761            // the cell resolves past a concealed run rather than to its head.
762            // `crate::ropetext::Layout::position_at_cell` steps the same way, and this
763            // is what keeps `position_at_cell ∘ cell_of` monotone — the reason
764            // to prefer it over "the first position at this cell boundary",
765            // which lands the caret inside the markup that was concealed.
766            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    /// Mapping an arbitrary column must not reveal the element that column
782    /// happens to land in — only the caret reveals.
783    ///
784    /// A wikilink renders with its `[[` `]]` hidden, so `[[note]] x` draws as
785    /// `note x`. Asking for the rendered column of a boundary *inside* the
786    /// link used to answer as though the link were expanded, counting the four
787    /// hidden sigil chars as drawn, and every highlight anchored there landed
788    /// four cells right of its text (the replace preview, and selections whose
789    /// edge fell inside a link).
790    #[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        // Caret elsewhere (or absent): the link stays collapsed, so logical
796        // col 2 — the "n" of "note" — is rendered col 0.
797        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        // Caret inside the link: it is revealed raw, so the same logical
805        // column now really is two cells in.
806        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        // The legacy entry point maps the caret, so it must keep agreeing with
811        // the revealed case — this is the behaviour every existing caller has.
812        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        // "\tx" force-raw: tab at col 0 → TAB_STOP (4) spaces, then 'x' → 5 cols.
822        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        // Cursor after the tab (logical col 1) is at rendered col 4 (tab width).
827        let rc = MarkdownSpanner::rendered_cursor_col("\tx", 0, 1, true, true);
828        assert_eq!(rc, 4);
829        // Cursor after 'x' (logical col 2) is at rendered col 5.
830        let rc2 = MarkdownSpanner::rendered_cursor_col("\tx", 0, 2, true, true);
831        assert_eq!(rc2, 5);
832
833        // Inverse: rendered col 4 maps back to logical col 1 ('x'); col 0 → 0.
834        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        // Cursor-row blockquote (markers revealed, no gutter): rendered_col 0/1
847        // map to the '>' and ' ' (logical 0/1), rendered_col 2 to 'h' (logical 2)
848        // — not skipped as hidden.
849        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        // Cursor on the line → "> " revealed (both chars visible).
862        let with_cursor = MarkdownSpanner::visible_positions("> hi", Some(2), false);
863        assert_eq!(&with_cursor[0..2], &[true, true]);
864
865        // Cursor off the line → "> " hidden (gutter draws the bar instead).
866        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        // Cursor (col 4) sits inside the bold span of "> **b**". elem_at resolves
873        // to the Bold element (start_char=2, end_char=7), not the line-spanning
874        // Blockquote, so only the new blockquote-sigil reveal keeps the "> "
875        // marker (cols 0,1) visible.
876        //
877        // Parsed: Blockquote [0,7), Bold [2,7); blockquote_sigil_end = Some(4).
878        // Without in_blockquote_sigil: cols 0,1 are in_any_element=true but
879        // in_expanded=false → hidden. With it: pos < 4 → visible.
880        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        // Cursor just after "> " on a bare blockquote line. Rendered column must
887        // be 2 (the "> " is revealed and visible on the cursor's own line), not 0.
888        let col = MarkdownSpanner::rendered_cursor_col(
889            "> ",  // logical line
890            0,     // visual_start_col
891            2,     // cursor_col (end of line)
892            true,  // is_first_visual_line
893            false, // force_raw
894        );
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        // cursor_col = None → bar gutter, raw "> " hidden.
902        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        // cursor_col = Some(..) → raw "> hi" shown, no bar.
916        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}