Skip to main content

okf_studio/
markdown.rs

1//! A purpose-built markdown → styled-lines renderer for the document pane.
2//!
3//! Not a general `CommonMark` engine: OKF bodies are deliberately simple, and
4//! okf-core already ships the hard parts (heading extraction, link parsing,
5//! footnote scanning). The renderer's extra job over plain text is the link
6//! focus map: every rendered link and footnote reference records its line so
7//! the viewer can Tab-cycle and Enter-follow them.
8
9use crate::theme::{GLYPH_BROKEN, GLYPH_OK, Theme};
10use okf_core::markdown::parse_heading_line;
11use okf_core::{Link, LinkKind};
12use okf_validator::{Language, check_syntax};
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use std::collections::HashMap;
16
17/// What a focusable element in the rendered document points at.
18#[derive(Clone, Debug)]
19pub enum FocusKind {
20    /// A markdown link, classified.
21    Link {
22        /// The link text.
23        text: String,
24        /// The raw destination.
25        target: String,
26        /// The destination's classification.
27        kind: LinkKind,
28    },
29    /// A `[^label]` footnote reference.
30    Footnote(String),
31}
32
33/// One focusable element and the rendered line it starts on.
34#[derive(Clone, Debug)]
35pub struct FocusTarget {
36    /// 0-based index into [`RenderedDoc::lines`].
37    pub line: usize,
38    /// What the element points at.
39    pub kind: FocusKind,
40}
41
42/// A heading's position in the rendered output.
43#[derive(Clone, Debug)]
44pub struct HeadingPos {
45    /// 0-based index into [`RenderedDoc::lines`].
46    pub line: usize,
47    /// Heading level (1–6).
48    pub level: usize,
49    /// The heading text.
50    pub text: String,
51}
52
53/// The rendered document: styled lines plus the focus and jump maps.
54#[derive(Clone, Debug, Default)]
55pub struct RenderedDoc {
56    /// The styled output, one entry per terminal row (before scrolling).
57    pub lines: Vec<Line<'static>>,
58    /// Focusable links and footnote refs, in document order.
59    pub links: Vec<FocusTarget>,
60    /// Headings, for the outline jump list.
61    pub headings: Vec<HeadingPos>,
62    /// Rendered line of each `[^label]:` definition, the Enter-jump target
63    /// for a focused footnote reference.
64    pub footnote_defs: HashMap<String, usize>,
65}
66
67/// Display width of a char: a small `wcwidth` approximation on std.
68#[must_use]
69pub const fn char_width(c: char) -> usize {
70    let cp = c as u32;
71    if c.is_control() {
72        return 0;
73    }
74    let wide = matches!(
75        cp,
76        0x1100..=0x115F
77            | 0x2E80..=0xA4CF
78            | 0xAC00..=0xD7A3
79            | 0xF900..=0xFAFF
80            | 0xFE30..=0xFE4F
81            | 0xFF00..=0xFF60
82            | 0xFFE0..=0xFFE6
83            | 0x1F300..=0x1FAFF
84            | 0x20000..=0x3FFFD
85    );
86    if wide { 2 } else { 1 }
87}
88
89/// Display width of a string.
90#[must_use]
91pub fn str_width(s: &str) -> usize {
92    s.chars().map(char_width).sum()
93}
94
95/// Truncates `s` to at most `max` display columns, appending `…` when cut.
96#[must_use]
97pub fn truncate_to_width(s: &str, max: usize) -> String {
98    if str_width(s) <= max {
99        return s.to_string();
100    }
101    let mut out = String::new();
102    let mut w = 0;
103    for c in s.chars() {
104        let cw = char_width(c);
105        if w + cw + 1 > max {
106            break;
107        }
108        out.push(c);
109        w += cw;
110    }
111    out.push('…');
112    out
113}
114
115/// An inline fragment: text with one style and an optional focus id.
116#[derive(Clone, Debug)]
117struct Frag {
118    text: String,
119    style: Style,
120    focus: Option<usize>,
121}
122
123/// Renders a document body at a pane width.
124///
125/// `focused` selects which entry of the returned focus map renders with the
126/// selection style; pass the previously returned map's index.
127#[must_use]
128#[allow(clippy::too_many_lines)]
129pub fn render_document(
130    body: &str,
131    width: u16,
132    theme: &Theme,
133    focused: Option<usize>,
134) -> RenderedDoc {
135    let width = usize::from(width.max(10));
136    let mut doc = RenderedDoc::default();
137    let lines: Vec<&str> = body.lines().collect();
138    let mut i = 0;
139
140    while i < lines.len() {
141        let line = lines[i];
142        let trimmed = line.trim_start();
143
144        // Fenced code block.
145        if let Some(marker) = fence_marker(trimmed) {
146            let lang_tag = trimmed[3..].trim().to_string();
147            let mut code: Vec<&str> = Vec::new();
148            let mut j = i + 1;
149            while j < lines.len() && fence_marker(lines[j].trim_start()) != Some(marker) {
150                code.push(lines[j]);
151                j += 1;
152            }
153            render_code_block(&mut doc, &code, &lang_tag, width, *theme);
154            i = if j < lines.len() { j + 1 } else { j };
155            continue;
156        }
157
158        // Heading.
159        if let Some((level, text)) = parse_heading_line(line) {
160            let style = theme.accent().add_modifier(Modifier::BOLD);
161            let indent = " ".repeat(level.saturating_sub(1));
162            doc.headings.push(HeadingPos {
163                line: doc.lines.len(),
164                level,
165                text: text.to_string(),
166            });
167            let frags = parse_inline(text, style, *theme, &mut doc, focused);
168            let prefix = Span::styled(indent, Style::default());
169            push_wrapped(
170                &mut doc,
171                &frags,
172                width,
173                std::slice::from_ref(&prefix),
174                std::slice::from_ref(&prefix),
175            );
176            if level == 1 {
177                doc.lines
178                    .push(Line::from(Span::styled("─".repeat(width), theme.dim())));
179            }
180            i += 1;
181            continue;
182        }
183
184        // Horizontal rule.
185        if is_hr(trimmed) {
186            doc.lines
187                .push(Line::from(Span::styled("─".repeat(width), theme.dim())));
188            i += 1;
189            continue;
190        }
191
192        // Table: a run of `|`-prefixed lines.
193        if trimmed.starts_with('|') {
194            let mut j = i;
195            while j < lines.len() && lines[j].trim_start().starts_with('|') {
196                j += 1;
197            }
198            render_table(&mut doc, &lines[i..j], width, *theme);
199            i = j;
200            continue;
201        }
202
203        // Blank line.
204        if trimmed.is_empty() {
205            doc.lines.push(Line::default());
206            i += 1;
207            continue;
208        }
209
210        // Footnote definition.
211        if let Some((label, rest)) = footnote_def(trimmed) {
212            doc.footnote_defs.insert(label.clone(), doc.lines.len());
213            let marker = Span::styled(format!("[^{label}] "), theme.accent());
214            let cont = Span::styled(
215                " ".repeat(str_width(&format!("[^{label}] "))),
216                Style::default(),
217            );
218            let frags = parse_inline(rest, theme.dim(), *theme, &mut doc, focused);
219            push_wrapped(
220                &mut doc,
221                &frags,
222                width,
223                std::slice::from_ref(&marker),
224                std::slice::from_ref(&cont),
225            );
226            i += 1;
227            continue;
228        }
229
230        // Block quote.
231        if let Some(rest) = trimmed.strip_prefix('>') {
232            let gutter = Span::styled("│ ", theme.dim());
233            let frags = parse_inline(rest.trim_start(), theme.dim(), *theme, &mut doc, focused);
234            push_wrapped(
235                &mut doc,
236                &frags,
237                width,
238                std::slice::from_ref(&gutter),
239                std::slice::from_ref(&gutter),
240            );
241            i += 1;
242            continue;
243        }
244
245        // List item (bullet, numbered, task).
246        if let Some((marker, rest)) = list_marker(line) {
247            let indent = line.len() - trimmed.len();
248            let pad = " ".repeat(indent);
249            let first = Span::styled(format!("{pad}{marker} "), theme.accent());
250            let cont = Span::raw(" ".repeat(indent + str_width(&marker) + 1));
251            let frags = parse_inline(rest, Style::default(), *theme, &mut doc, focused);
252            push_wrapped(
253                &mut doc,
254                &frags,
255                width,
256                std::slice::from_ref(&first),
257                std::slice::from_ref(&cont),
258            );
259            i += 1;
260            continue;
261        }
262
263        // Paragraph line.
264        let frags = parse_inline(line, Style::default(), *theme, &mut doc, focused);
265        push_wrapped(&mut doc, &frags, width, &[], &[]);
266        i += 1;
267    }
268
269    doc
270}
271
272fn fence_marker(trimmed: &str) -> Option<char> {
273    if trimmed.starts_with("```") {
274        Some('`')
275    } else if trimmed.starts_with("~~~") {
276        Some('~')
277    } else {
278        None
279    }
280}
281
282fn is_hr(trimmed: &str) -> bool {
283    trimmed.len() >= 3
284        && (trimmed.chars().all(|c| c == '-')
285            || trimmed.chars().all(|c| c == '*')
286            || trimmed.chars().all(|c| c == '_'))
287}
288
289/// Parses `[^label]: rest` definitions.
290fn footnote_def(trimmed: &str) -> Option<(String, &str)> {
291    let inner = trimmed.strip_prefix("[^")?;
292    let close = inner.find("]:")?;
293    let label = inner[..close].trim();
294    if label.is_empty() {
295        return None;
296    }
297    Some((label.to_string(), inner[close + 2..].trim_start()))
298}
299
300/// Recognizes `- `, `* `, `+ `, `1. `, and task-list markers, returning the
301/// rendered marker and the item text.
302fn list_marker(line: &str) -> Option<(String, &str)> {
303    let trimmed = line.trim_start();
304    for bullet in ["- [ ] ", "* [ ] "] {
305        if let Some(rest) = trimmed.strip_prefix(bullet) {
306            return Some(("☐".to_string(), rest));
307        }
308    }
309    for bullet in ["- [x] ", "* [x] ", "- [X] ", "* [X] "] {
310        if let Some(rest) = trimmed.strip_prefix(bullet) {
311            return Some(("☑".to_string(), rest));
312        }
313    }
314    for bullet in ["- ", "* ", "+ "] {
315        if let Some(rest) = trimmed.strip_prefix(bullet) {
316            return Some(("•".to_string(), rest));
317        }
318    }
319    // Numbered: digits then `. ` or `) `.
320    let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
321    if !digits.is_empty() {
322        let rest = &trimmed[digits.len()..];
323        if let Some(text) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
324            return Some((format!("{digits}."), text));
325        }
326    }
327    None
328}
329
330/// Renders a fenced code block as a boxed region with the language tag and a
331/// syntax verdict badge in the top border.
332fn render_code_block(
333    doc: &mut RenderedDoc,
334    code: &[&str],
335    lang_tag: &str,
336    width: usize,
337    theme: Theme,
338) {
339    let inner = width.saturating_sub(4).max(4);
340    let source = code.join("\n");
341    let verdict = if lang_tag.is_empty() || Language::from_tag(lang_tag) == Language::Unknown {
342        None
343    } else {
344        Some(check_syntax(lang_tag, &source))
345    };
346    let mut title = String::new();
347    if !lang_tag.is_empty() {
348        title.push_str(lang_tag);
349    }
350    let (badge, badge_style) = match &verdict {
351        Some(Ok(())) => (format!(" syntax {GLYPH_OK}"), theme.ok()),
352        Some(Err(e)) => (format!(" syntax {GLYPH_BROKEN} {e}"), theme.error()),
353        None => (String::new(), theme.dim()),
354    };
355    let head = format!("┌ {title}");
356    let head_width = str_width(&head) + str_width(&badge);
357    let fill = width.saturating_sub(head_width + 2);
358    doc.lines.push(Line::from(vec![
359        Span::styled(head, theme.dim()),
360        Span::styled(
361            truncate_to_width(&badge, width.saturating_sub(4)),
362            badge_style,
363        ),
364        Span::styled(format!(" {}", "─".repeat(fill)), theme.dim()),
365    ]));
366    for line in code {
367        let text = truncate_to_width(line, inner);
368        doc.lines.push(Line::from(vec![
369            Span::styled("│ ", theme.dim()),
370            Span::raw(text),
371        ]));
372    }
373    doc.lines.push(Line::from(Span::styled(
374        format!("└{}", "─".repeat(width.saturating_sub(1))),
375        theme.dim(),
376    )));
377}
378
379/// Renders a run of `|`-delimited rows: box-drawn when the columns fit the
380/// pane, otherwise emitted as preformatted text.
381fn render_table(doc: &mut RenderedDoc, rows: &[&str], width: usize, theme: Theme) {
382    let parsed: Vec<Vec<String>> = rows
383        .iter()
384        .filter(|r| !is_table_separator(r))
385        .map(|r| {
386            r.trim()
387                .trim_matches('|')
388                .split('|')
389                .map(|c| c.trim().to_string())
390                .collect()
391        })
392        .collect();
393    if parsed.is_empty() {
394        return;
395    }
396    let cols = parsed.iter().map(Vec::len).max().unwrap_or(0);
397    let mut widths = vec![0usize; cols];
398    for row in &parsed {
399        for (c, cell) in row.iter().enumerate() {
400            widths[c] = widths[c].max(str_width(cell));
401        }
402    }
403    let total: usize = widths.iter().sum::<usize>() + cols * 3 + 1;
404    if total > width {
405        // Preformatted fallback.
406        for row in rows {
407            doc.lines.push(Line::from(Span::raw((*row).to_string())));
408        }
409        return;
410    }
411    let rule = |l: &str, m: &str, r: &str| {
412        let mut s = String::from(l);
413        for (c, w) in widths.iter().enumerate() {
414            s.push_str(&"─".repeat(w + 2));
415            s.push_str(if c + 1 == cols { r } else { m });
416        }
417        Line::from(Span::styled(s, theme.dim()))
418    };
419    doc.lines.push(rule("┌", "┬", "┐"));
420    for (r, row) in parsed.iter().enumerate() {
421        let mut spans = vec![Span::styled("│", theme.dim())];
422        for (c, w) in widths.iter().enumerate() {
423            let cell = row.get(c).map_or("", String::as_str);
424            let pad = w.saturating_sub(str_width(cell));
425            let style = if r == 0 {
426                Style::default().add_modifier(Modifier::BOLD)
427            } else {
428                Style::default()
429            };
430            spans.push(Span::styled(format!(" {cell}{} ", " ".repeat(pad)), style));
431            spans.push(Span::styled("│", theme.dim()));
432        }
433        doc.lines.push(Line::from(spans));
434        if r == 0 && parsed.len() > 1 {
435            doc.lines.push(rule("├", "┼", "┤"));
436        }
437    }
438    doc.lines.push(rule("└", "┴", "┘"));
439}
440
441fn is_table_separator(row: &str) -> bool {
442    let t = row.trim().trim_matches('|');
443    !t.is_empty() && t.chars().all(|c| matches!(c, '-' | ':' | '|' | ' '))
444}
445
446/// Parses inline markdown (bold, italic, code, links, footnote refs) into
447/// styled fragments, registering focusable elements in `doc.links`.
448#[allow(clippy::too_many_lines)]
449fn parse_inline(
450    text: &str,
451    base: Style,
452    theme: Theme,
453    doc: &mut RenderedDoc,
454    focused: Option<usize>,
455) -> Vec<Frag> {
456    let chars: Vec<char> = text.chars().collect();
457    let mut frags: Vec<Frag> = Vec::new();
458    let mut buf = String::new();
459    let mut i = 0;
460    let mut bold = false;
461    let mut italic = false;
462
463    let flush = |buf: &mut String, frags: &mut Vec<Frag>, bold: bool, italic: bool| {
464        if buf.is_empty() {
465            return;
466        }
467        let mut style = base;
468        if bold {
469            style = style.add_modifier(Modifier::BOLD);
470        }
471        if italic {
472            style = style.add_modifier(Modifier::ITALIC);
473        }
474        frags.push(Frag {
475            text: std::mem::take(buf),
476            style,
477            focus: None,
478        });
479    };
480
481    while i < chars.len() {
482        let c = chars[i];
483        // Inline code span.
484        if c == '`'
485            && let Some(close) = chars[i + 1..].iter().position(|&x| x == '`')
486        {
487            flush(&mut buf, &mut frags, bold, italic);
488            let code: String = chars[i + 1..i + 1 + close].iter().collect();
489            frags.push(Frag {
490                text: code,
491                style: theme.accent().add_modifier(Modifier::DIM),
492                focus: None,
493            });
494            i += close + 2;
495            continue;
496        }
497        // Bold / italic toggles.
498        if c == '*' {
499            if chars.get(i + 1) == Some(&'*') {
500                flush(&mut buf, &mut frags, bold, italic);
501                bold = !bold;
502                i += 2;
503                continue;
504            }
505            flush(&mut buf, &mut frags, bold, italic);
506            italic = !italic;
507            i += 1;
508            continue;
509        }
510        // Footnote reference.
511        if c == '['
512            && chars.get(i + 1) == Some(&'^')
513            && let Some(close) = chars[i + 2..].iter().position(|&x| x == ']')
514        {
515            let label: String = chars[i + 2..i + 2 + close].iter().collect();
516            if !label.is_empty() && chars.get(i + 2 + close + 1) != Some(&':') {
517                flush(&mut buf, &mut frags, bold, italic);
518                let focus_id = doc.links.len();
519                doc.links.push(FocusTarget {
520                    line: usize::MAX, // fixed up by push_wrapped
521                    kind: FocusKind::Footnote(label.clone()),
522                });
523                let mut style = theme.accent();
524                if focused == Some(focus_id) {
525                    style = style.add_modifier(Modifier::REVERSED);
526                }
527                frags.push(Frag {
528                    text: format!("[^{label}]"),
529                    style,
530                    focus: Some(focus_id),
531                });
532                i += 2 + close + 1;
533                continue;
534            }
535        }
536        // Markdown link.
537        if c == '['
538            && !okf_core::markdown::is_escaped(&chars, i)
539            && let Some((ltext, dest, next)) = okf_core::markdown::parse_inline_link(&chars, i)
540        {
541            flush(&mut buf, &mut frags, bold, italic);
542            let target = okf_core::markdown::clean_destination(&dest);
543            let kind = Link::classify(&target);
544            let focus_id = doc.links.len();
545            doc.links.push(FocusTarget {
546                line: usize::MAX,
547                kind: FocusKind::Link {
548                    text: ltext.clone(),
549                    target: target.clone(),
550                    kind,
551                },
552            });
553            let (mut style, rendered) = if kind == LinkKind::External {
554                (
555                    theme.accent().add_modifier(Modifier::UNDERLINED),
556                    ltext.clone(),
557                )
558            } else {
559                (base.add_modifier(Modifier::UNDERLINED), format!("→{ltext}"))
560            };
561            if focused == Some(focus_id) {
562                style = style.add_modifier(Modifier::REVERSED);
563            }
564            frags.push(Frag {
565                text: rendered,
566                style,
567                focus: Some(focus_id),
568            });
569            i = next;
570            continue;
571        }
572        buf.push(c);
573        i += 1;
574    }
575    flush(&mut buf, &mut frags, bold, italic);
576    frags
577}
578
579/// Word-wraps styled fragments to `width`, prefixing the first output line
580/// with `first_prefix` and continuations with `cont_prefix`, and fixes up the
581/// line index of any focus target the fragments carry.
582fn push_wrapped(
583    doc: &mut RenderedDoc,
584    frags: &[Frag],
585    width: usize,
586    first_prefix: &[Span<'static>],
587    cont_prefix: &[Span<'static>],
588) {
589    // Split fragments into atoms: words and whitespace runs, styles kept.
590    struct Atom {
591        text: String,
592        style: Style,
593        focus: Option<usize>,
594        is_space: bool,
595    }
596    let mut atoms: Vec<Atom> = Vec::new();
597    for frag in frags {
598        let mut cur = String::new();
599        let mut cur_space = None;
600        for c in frag.text.chars() {
601            let space = c == ' ';
602            if cur_space != Some(space) && !cur.is_empty() {
603                atoms.push(Atom {
604                    text: std::mem::take(&mut cur),
605                    style: frag.style,
606                    focus: frag.focus,
607                    is_space: cur_space == Some(true),
608                });
609            }
610            cur_space = Some(space);
611            cur.push(c);
612        }
613        if !cur.is_empty() {
614            atoms.push(Atom {
615                text: cur,
616                style: frag.style,
617                focus: frag.focus,
618                is_space: cur_space == Some(true),
619            });
620        }
621    }
622
623    let prefix_width: usize = first_prefix.iter().map(|s| str_width(&s.content)).sum();
624    let avail = width.saturating_sub(prefix_width).max(8);
625    let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()];
626    let mut cur_width = 0usize;
627    let mut focus_lines: Vec<(usize, usize)> = Vec::new();
628
629    for atom in atoms {
630        let w = str_width(&atom.text);
631        if cur_width + w > avail && cur_width > 0 && !atom.is_space {
632            lines.push(Vec::new());
633            cur_width = 0;
634        }
635        if atom.is_space && cur_width == 0 && lines.last().is_some_and(Vec::is_empty) {
636            continue; // drop leading spaces on wrapped lines
637        }
638        if let Some(f) = atom.focus {
639            focus_lines.push((f, lines.len() - 1));
640        }
641        lines
642            .last_mut()
643            .expect("lines is never empty")
644            .push(Span::styled(atom.text, atom.style));
645        cur_width += w;
646    }
647
648    let base = doc.lines.len();
649    for (idx, spans) in lines.into_iter().enumerate() {
650        let mut full = if idx == 0 {
651            first_prefix.to_vec()
652        } else {
653            cont_prefix.to_vec()
654        };
655        full.extend(spans);
656        doc.lines.push(Line::from(full));
657    }
658    for (f, rel) in focus_lines {
659        if let Some(target) = doc.links.get_mut(f) {
660            target.line = base + rel;
661        }
662    }
663}