Skip to main content

dot/tui/
markdown.rs

1use std::sync::LazyLock;
2
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use syntect::highlighting::ThemeSet;
6use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxSet};
7
8use crate::tui::theme::{SyntaxStyles, Theme};
9
10static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
11static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
12
13#[derive(Clone, Copy)]
14enum ScopeKind {
15    Keyword,
16    Str,
17    Comment,
18    Function,
19    Type,
20    Number,
21    Constant,
22    Attribute,
23}
24
25static SCOPE_MATCHERS: LazyLock<Vec<(Scope, ScopeKind)>> = LazyLock::new(|| {
26    use ScopeKind::*;
27    [
28        ("entity.other.attribute-name", Attribute),
29        ("entity.name.function", Function),
30        ("entity.name.type", Type),
31        ("entity.name.class", Type),
32        ("entity.name.tag", Keyword),
33        ("constant.character", Str),
34        ("constant.language", Constant),
35        ("constant.numeric", Number),
36        ("support.function", Function),
37        ("support.type", Type),
38        ("variable.language", Keyword),
39        ("meta.attribute", Attribute),
40        ("keyword", Keyword),
41        ("storage", Keyword),
42        ("comment", Comment),
43        ("string", Str),
44    ]
45    .into_iter()
46    .filter_map(|(s, kind)| Some((Scope::new(s).ok()?, kind)))
47    .collect()
48});
49
50fn resolve_scope(stack: &ScopeStack, styles: &SyntaxStyles) -> Style {
51    for scope in stack.as_slice().iter().rev() {
52        for (prefix, kind) in SCOPE_MATCHERS.iter() {
53            if prefix.is_prefix_of(*scope) {
54                return match kind {
55                    ScopeKind::Keyword => styles.keyword,
56                    ScopeKind::Str => styles.string,
57                    ScopeKind::Comment => styles.comment,
58                    ScopeKind::Function => styles.function,
59                    ScopeKind::Type => styles.type_name,
60                    ScopeKind::Number => styles.number,
61                    ScopeKind::Constant => styles.constant,
62                    ScopeKind::Attribute => styles.attribute,
63                };
64            }
65        }
66    }
67    Style::default()
68}
69
70fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
71    if max_width == 0 {
72        return vec![text.to_string()];
73    }
74    let mut result: Vec<String> = Vec::new();
75    for raw in text.lines() {
76        if raw.is_empty() {
77            result.push(String::new());
78            continue;
79        }
80        let mut current = String::new();
81        let mut current_len: usize = 0;
82        for word in raw.split_whitespace() {
83            let word_len = word.chars().count();
84            if current.is_empty() {
85                current.push_str(word);
86                current_len = word_len;
87            } else if current_len + 1 + word_len <= max_width {
88                current.push(' ');
89                current.push_str(word);
90                current_len += 1 + word_len;
91            } else {
92                result.push(std::mem::take(&mut current));
93                current.push_str(word);
94                current_len = word_len;
95            }
96        }
97        if !current.is_empty() {
98            result.push(current);
99        }
100    }
101    if result.is_empty() {
102        result.push(String::new());
103    }
104    result
105}
106
107fn truncate_code_line(line: &str, max_chars: usize) -> String {
108    if line.chars().count() <= max_chars {
109        return line.to_string();
110    }
111    let truncated: String = line.chars().take(max_chars.saturating_sub(1)).collect();
112    format!("{}…", truncated)
113}
114
115pub fn render_markdown(text: &str, theme: &Theme, width: u16) -> Vec<Line<'static>> {
116    let mut lines: Vec<Line<'static>> = Vec::new();
117    let mut in_code_block = false;
118    let mut code_lang = String::new();
119    let mut code_lines: Vec<String> = Vec::new();
120    let mut just_closed_code = false;
121
122    for raw_line in text.lines() {
123        if raw_line.starts_with("```") {
124            if in_code_block {
125                render_code_block(&code_lang, &code_lines, theme, width, &mut lines);
126                code_lines.clear();
127                code_lang.clear();
128                in_code_block = false;
129                just_closed_code = true;
130            } else {
131                in_code_block = true;
132                code_lang = raw_line.trim_start_matches('`').trim().to_string();
133                if let Some(last) = lines.last() {
134                    if last.spans.iter().all(|s| s.content.trim().is_empty()) {
135                        lines.pop();
136                    }
137                }
138            }
139            continue;
140        }
141
142        if in_code_block {
143            code_lines.push(raw_line.to_string());
144            continue;
145        }
146
147        if raw_line.is_empty() {
148            if just_closed_code {
149                continue;
150            }
151            lines.push(Line::from(""));
152            continue;
153        }
154        just_closed_code = false;
155
156        if let Some(heading) = raw_line.strip_prefix("### ") {
157            lines.push(Line::from(Span::styled(
158                heading.to_string(),
159                theme
160                    .heading
161                    .patch(Style::default().add_modifier(Modifier::BOLD)),
162            )));
163        } else if let Some(heading) = raw_line.strip_prefix("## ") {
164            lines.push(Line::from(Span::styled(heading.to_string(), theme.heading)));
165        } else if let Some(heading) = raw_line.strip_prefix("# ") {
166            lines.push(Line::from(Span::styled(
167                heading.to_string(),
168                theme
169                    .heading
170                    .patch(Style::default().add_modifier(Modifier::BOLD)),
171            )));
172        } else if let Some(quote) = raw_line.strip_prefix("> ") {
173            lines.push(Line::from(vec![
174                Span::styled("  │ ", theme.blockquote),
175                Span::styled(quote.to_string(), theme.blockquote),
176            ]));
177        } else if raw_line.starts_with("- ") || raw_line.starts_with("* ") {
178            let content = &raw_line[2..];
179            let prefix_len = 4usize;
180            let wrap_w = (width as usize).saturating_sub(prefix_len);
181            let sub_lines = word_wrap(content, wrap_w);
182            for (i, sub) in sub_lines.into_iter().enumerate() {
183                if i == 0 {
184                    let spans = parse_inline(&sub, theme);
185                    let mut full = vec![Span::styled("  \u{00b7} ", theme.list_bullet)];
186                    full.extend(spans);
187                    lines.push(Line::from(full));
188                } else {
189                    let spans = parse_inline(&sub, theme);
190                    let mut full = vec![Span::raw("    ")];
191                    full.extend(spans);
192                    lines.push(Line::from(full));
193                }
194            }
195        } else if raw_line
196            .chars()
197            .next()
198            .map(|c| c.is_ascii_digit())
199            .unwrap_or(false)
200            && raw_line.contains(". ")
201        {
202            if let Some(pos) = raw_line.find(". ") {
203                let num = &raw_line[..pos + 2];
204                let content = &raw_line[pos + 2..];
205                let prefix_len = num.chars().count() + 3;
206                let wrap_w = (width as usize).saturating_sub(prefix_len);
207                let sub_lines = word_wrap(content, wrap_w);
208                let indent = " ".repeat(prefix_len);
209                for (i, sub) in sub_lines.into_iter().enumerate() {
210                    if i == 0 {
211                        let spans = parse_inline(&sub, theme);
212                        let mut full = vec![Span::styled(format!("  {} ", num), theme.list_bullet)];
213                        full.extend(spans);
214                        lines.push(Line::from(full));
215                    } else {
216                        let spans = parse_inline(&sub, theme);
217                        let mut full = vec![Span::raw(indent.clone())];
218                        full.extend(spans);
219                        lines.push(Line::from(full));
220                    }
221                }
222            }
223        } else if raw_line.trim() == "---" || raw_line.trim() == "***" {
224            lines.push(Line::from(Span::styled(
225                "\u{2500}".repeat(width.saturating_sub(4) as usize),
226                theme.border,
227            )));
228        } else {
229            let sub_lines = word_wrap(raw_line, width as usize);
230            for sub in sub_lines {
231                let spans = parse_inline(&sub, theme);
232                lines.push(Line::from(spans));
233            }
234        }
235    }
236
237    if in_code_block {
238        render_code_block(&code_lang, &code_lines, theme, width, &mut lines);
239    }
240
241    let mut deduped: Vec<Line<'static>> = Vec::with_capacity(lines.len());
242    let mut prev_empty = false;
243    for line in lines {
244        let is_empty = line.spans.iter().all(|s| s.content.is_empty());
245        if is_empty && prev_empty {
246            continue;
247        }
248        prev_empty = is_empty;
249        deduped.push(line);
250    }
251    deduped
252}
253
254pub fn render_code_block(
255    lang: &str,
256    code_lines: &[String],
257    theme: &Theme,
258    width: u16,
259    output: &mut Vec<Line<'static>>,
260) {
261    let w = width as usize;
262    let bg = theme.code_bg;
263    let pad = " ";
264    let pad_len = 1;
265
266    output.push(Line::from(""));
267
268    let fill = |content_len: usize| -> String { " ".repeat(w.saturating_sub(content_len)) };
269
270    // Top line: language badge or blank, all on code_bg
271    if !lang.is_empty() {
272        let badge = format!("{}{}", pad, lang);
273        let badge_len = badge.chars().count();
274        output.push(Line::from(vec![
275            Span::styled(badge, Style::default().fg(theme.muted_fg).bg(bg)),
276            Span::styled(fill(badge_len), Style::default().bg(bg)),
277        ]));
278    } else {
279        output.push(Line::from(Span::styled(
280            " ".repeat(w),
281            Style::default().bg(bg),
282        )));
283    }
284
285    let is_diff = lang == "diff" || lang == "patch";
286    if is_diff {
287        for raw_line in code_lines {
288            let line = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
289            let diff_style = if line.starts_with('+') {
290                theme.diff_add.bg(bg)
291            } else if line.starts_with('-') {
292                theme.diff_remove.bg(bg)
293            } else if line.starts_with('@') {
294                theme.diff_hunk.bg(bg)
295            } else {
296                Style::default().fg(theme.fg).bg(bg)
297            };
298            let content = format!("{}{}", pad, line);
299            let content_len = content.chars().count();
300            output.push(Line::from(vec![
301                Span::styled(content, diff_style),
302                Span::styled(fill(content_len), Style::default().bg(bg)),
303            ]));
304        }
305        if code_lines.is_empty() {
306            output.push(Line::from(Span::styled(
307                " ".repeat(w),
308                Style::default().bg(bg),
309            )));
310        }
311    } else if let Some(syntect_theme_name) = theme.syntect_theme
312        && !lang.is_empty()
313        && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang)
314        && let Some(st_theme) = THEME_SET.themes.get(syntect_theme_name)
315    {
316        let mut highlighter = syntect::easy::HighlightLines::new(syntax, st_theme);
317        for raw_line in code_lines {
318            let line: &str = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
319            let highlighted = highlighter.highlight_line(line, &SYNTAX_SET);
320            match highlighted {
321                Ok(ranges) => {
322                    let mut spans = vec![Span::styled(pad, Style::default().bg(bg))];
323                    let mut content_len = pad_len;
324                    for (style, text) in ranges {
325                        let fg = style.foreground;
326                        let clean = text.trim_end_matches('\n');
327                        if clean.is_empty() {
328                            continue;
329                        }
330                        content_len += clean.chars().count();
331                        spans.push(Span::styled(
332                            clean.to_string(),
333                            Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)).bg(bg),
334                        ));
335                    }
336                    spans.push(Span::styled(fill(content_len), Style::default().bg(bg)));
337                    output.push(Line::from(spans));
338                }
339                Err(_) => {
340                    let content = format!("{}{}", pad, line);
341                    let content_len = content.chars().count();
342                    output.push(Line::from(vec![
343                        Span::styled(content, Style::default().fg(theme.fg).bg(bg)),
344                        Span::styled(fill(content_len), Style::default().bg(bg)),
345                    ]));
346                }
347            }
348        }
349        if code_lines.is_empty() {
350            output.push(Line::from(Span::styled(
351                " ".repeat(w),
352                Style::default().bg(bg),
353            )));
354        }
355    } else if let Some(styles) = &theme.syntax
356        && !lang.is_empty()
357        && let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang)
358    {
359        let mut state = ParseState::new(syntax);
360        let mut stack = ScopeStack::new();
361        for raw_line in code_lines {
362            let line = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
363            match state.parse_line(line, &SYNTAX_SET) {
364                Ok(ops) => {
365                    let mut spans = vec![Span::styled(pad, Style::default().bg(bg))];
366                    let mut content_len = pad_len;
367                    let mut prev = 0;
368                    for (pos, op) in &ops {
369                        let pos = (*pos).min(line.len());
370                        if pos > prev {
371                            let text = &line[prev..pos];
372                            content_len += text.chars().count();
373                            spans.push(Span::styled(
374                                text.to_string(),
375                                resolve_scope(&stack, styles).bg(bg),
376                            ));
377                        }
378                        let _ = stack.apply(op);
379                        prev = pos;
380                    }
381                    if prev < line.len() {
382                        let text = &line[prev..];
383                        content_len += text.chars().count();
384                        spans.push(Span::styled(
385                            text.to_string(),
386                            resolve_scope(&stack, styles).bg(bg),
387                        ));
388                    }
389                    spans.push(Span::styled(fill(content_len), Style::default().bg(bg)));
390                    output.push(Line::from(spans));
391                }
392                Err(_) => {
393                    let content = format!("{}{}", pad, line);
394                    let content_len = content.chars().count();
395                    output.push(Line::from(vec![
396                        Span::styled(content, Style::default().fg(theme.fg).bg(bg)),
397                        Span::styled(fill(content_len), Style::default().bg(bg)),
398                    ]));
399                }
400            }
401        }
402        if code_lines.is_empty() {
403            output.push(Line::from(Span::styled(
404                " ".repeat(w),
405                Style::default().bg(bg),
406            )));
407        }
408    } else {
409        let code_style = Style::default().fg(theme.fg).bg(bg);
410        for raw_line in code_lines {
411            let line = &truncate_code_line(raw_line, w.saturating_sub(pad_len));
412            let content = format!("{}{}", pad, line);
413            let content_len = content.chars().count();
414            output.push(Line::from(vec![
415                Span::styled(content, code_style),
416                Span::styled(fill(content_len), Style::default().bg(bg)),
417            ]));
418        }
419        if code_lines.is_empty() {
420            output.push(Line::from(Span::styled(
421                " ".repeat(w),
422                Style::default().bg(bg),
423            )));
424        }
425    }
426
427    output.push(Line::from(""));
428}
429
430#[allow(clippy::while_let_on_iterator)]
431fn parse_inline(text: &str, theme: &Theme) -> Vec<Span<'static>> {
432    let mut spans: Vec<Span<'static>> = Vec::new();
433    let mut chars = text.char_indices().peekable();
434    let mut current = String::new();
435
436    while let Some((_i, c)) = chars.next() {
437        match c {
438            '`' => {
439                if !current.is_empty() {
440                    spans.push(Span::raw(std::mem::take(&mut current)));
441                }
442                let mut code = String::new();
443                let mut closed = false;
444                while let Some((_, ch)) = chars.next() {
445                    if ch == '`' {
446                        closed = true;
447                        break;
448                    }
449                    code.push(ch);
450                }
451                if closed {
452                    spans.push(Span::styled(code, theme.inline_code));
453                } else {
454                    spans.push(Span::raw(format!("`{}", code)));
455                }
456            }
457            '*' => {
458                let next_is_star = chars.peek().map(|(_, ch)| *ch == '*').unwrap_or(false);
459                if next_is_star {
460                    chars.next();
461                    if !current.is_empty() {
462                        spans.push(Span::raw(std::mem::take(&mut current)));
463                    }
464                    let mut bold_text = String::new();
465                    let mut closed = false;
466                    while let Some((_, ch)) = chars.next() {
467                        if ch == '*' && chars.peek().map(|(_, c)| *c == '*').unwrap_or(false) {
468                            chars.next();
469                            closed = true;
470                            break;
471                        }
472                        bold_text.push(ch);
473                    }
474                    if closed {
475                        spans.push(Span::styled(bold_text, theme.bold));
476                    } else {
477                        spans.push(Span::raw(format!("**{}", bold_text)));
478                    }
479                } else {
480                    if !current.is_empty() {
481                        spans.push(Span::raw(std::mem::take(&mut current)));
482                    }
483                    let mut italic_text = String::new();
484                    let mut closed = false;
485                    while let Some((_, ch)) = chars.next() {
486                        if ch == '*' {
487                            closed = true;
488                            break;
489                        }
490                        italic_text.push(ch);
491                    }
492                    if closed {
493                        spans.push(Span::styled(italic_text, theme.italic));
494                    } else {
495                        spans.push(Span::raw(format!("*{}", italic_text)));
496                    }
497                }
498            }
499            '[' => {
500                if !current.is_empty() {
501                    spans.push(Span::raw(std::mem::take(&mut current)));
502                }
503                let mut link_text = String::new();
504                let mut found_bracket = false;
505                while let Some((_, ch)) = chars.next() {
506                    if ch == ']' {
507                        found_bracket = true;
508                        break;
509                    }
510                    link_text.push(ch);
511                }
512                if found_bracket && chars.peek().map(|(_, c)| *c == '(').unwrap_or(false) {
513                    chars.next();
514                    let mut _url = String::new();
515                    while let Some((_, ch)) = chars.next() {
516                        if ch == ')' {
517                            break;
518                        }
519                        _url.push(ch);
520                    }
521                    spans.push(Span::styled(link_text, theme.link));
522                } else {
523                    spans.push(Span::raw(format!("[{}", link_text)));
524                    if found_bracket {
525                        spans.push(Span::raw("]"));
526                    }
527                }
528            }
529            _ => {
530                current.push(c);
531            }
532        }
533    }
534
535    if !current.is_empty() {
536        spans.push(Span::raw(current));
537    }
538
539    if spans.is_empty() {
540        spans.push(Span::raw(""));
541    }
542
543    spans
544}