Skip to main content

hjkl_markdown_tui/
lib.rs

1//! Ratatui adapter for `hjkl-markdown`.
2//!
3//! Converts a [`hjkl_markdown::Event`] stream into a `Vec<ratatui::text::Line>`
4//! suitable for rendering in a [`ratatui::widgets::Paragraph`] or similar widget.
5//!
6//! # Quick start
7//!
8//! ```rust
9//! use hjkl_markdown::parse;
10//! use hjkl_markdown_tui::{MdTheme, to_lines};
11//!
12//! let events = parse("# Title\n\nhello `world`");
13//! let theme = MdTheme::default();
14//! let lines = to_lines(&events, &theme, 60);
15//! assert!(!lines.is_empty());
16//! ```
17
18use hjkl_markdown::Event;
19use ratatui::{
20    style::{Color, Modifier, Style},
21    text::{Line, Span},
22};
23
24// ── MdTheme ───────────────────────────────────────────────────────────────────
25
26/// Color slots for markdown rendering in ratatui.
27///
28/// All fields are raw `ratatui::style::Color` values.  Build from your app's
29/// theme palette or use [`MdTheme::default`] for a sensible dark fallback.
30///
31/// `#[non_exhaustive]` — new slots may be added in minor releases.
32#[derive(Debug, Clone)]
33#[non_exhaustive]
34pub struct MdTheme {
35    /// Normal body text.
36    pub text: Color,
37    /// Level-1 heading foreground.
38    pub heading1: Color,
39    /// Level 2–6 heading foreground.
40    pub heading: Color,
41    /// Inline code span foreground.
42    pub code_span: Color,
43    /// Code block foreground.
44    pub code_block: Color,
45    /// Hyperlink foreground.
46    pub link: Color,
47    /// List bullet / ordinal foreground.
48    pub list_bullet: Color,
49    /// Bold text foreground.
50    pub bold: Color,
51    /// Italic text foreground.
52    pub italic: Color,
53    /// Horizontal rule foreground.
54    pub rule: Color,
55}
56
57impl MdTheme {
58    /// Construct a theme from explicit color values.
59    #[allow(clippy::too_many_arguments)]
60    pub fn new(
61        text: Color,
62        heading1: Color,
63        heading: Color,
64        code_span: Color,
65        code_block: Color,
66        link: Color,
67        list_bullet: Color,
68        bold: Color,
69        italic: Color,
70        rule: Color,
71    ) -> Self {
72        Self {
73            text,
74            heading1,
75            heading,
76            code_span,
77            code_block,
78            link,
79            list_bullet,
80            bold,
81            italic,
82            rule,
83        }
84    }
85}
86
87impl Default for MdTheme {
88    /// Dark fallback (Catppuccin-ish).
89    fn default() -> Self {
90        Self {
91            text: Color::Rgb(0xcd, 0xd6, 0xf4),
92            heading1: Color::Rgb(0xcb, 0xa6, 0xf7),
93            heading: Color::Rgb(0x89, 0xb4, 0xfa),
94            code_span: Color::Rgb(0xa6, 0xe3, 0xa1),
95            code_block: Color::Rgb(0xa6, 0xe3, 0xa1),
96            link: Color::Rgb(0x89, 0xdc, 0xeb),
97            list_bullet: Color::Rgb(0xf3, 0x8b, 0xa8),
98            bold: Color::Rgb(0xfa, 0xb3, 0x87),
99            italic: Color::Rgb(0xf9, 0xe2, 0xaf),
100            rule: Color::Rgb(0x58, 0x5b, 0x70),
101        }
102    }
103}
104
105// ── to_lines ──────────────────────────────────────────────────────────────────
106
107/// Convert a [`hjkl_markdown::Event`] slice into `ratatui::text::Line` rows.
108///
109/// `width` is the available column count — long lines are wrapped at word
110/// boundaries. Blank events become empty separator lines.
111pub fn to_lines(events: &[Event], theme: &MdTheme, width: u16) -> Vec<Line<'static>> {
112    let mut lines: Vec<Line<'static>> = Vec::new();
113    // Current line accumulator.
114    let mut current_spans: Vec<Span<'static>> = Vec::new();
115
116    let flush = |spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
117        if !spans.is_empty() {
118            lines.push(Line::from(std::mem::take(spans)));
119        }
120    };
121
122    for ev in events {
123        match ev {
124            Event::Heading { level, text } => {
125                flush(&mut current_spans, &mut lines);
126                let fg = if *level == 1 {
127                    theme.heading1
128                } else {
129                    theme.heading
130                };
131                let prefix = "#".repeat(*level as usize);
132                let label = format!("{prefix} {text}");
133                let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
134                for wrapped in wrap_str(&label, width as usize) {
135                    lines.push(Line::from(vec![Span::styled(wrapped, style)]));
136                }
137            }
138            Event::CodeBlock { lang, content } => {
139                flush(&mut current_spans, &mut lines);
140                if !lang.is_empty() {
141                    let lang_line = format!("[{lang}]");
142                    lines.push(Line::from(vec![Span::styled(
143                        lang_line,
144                        Style::default()
145                            .fg(theme.code_block)
146                            .add_modifier(Modifier::DIM),
147                    )]));
148                }
149                let style = Style::default().fg(theme.code_block);
150                for src_line in content.lines() {
151                    for wrapped in wrap_str(src_line, width as usize) {
152                        lines.push(Line::from(vec![Span::styled(wrapped, style)]));
153                    }
154                }
155            }
156            Event::Rule => {
157                flush(&mut current_spans, &mut lines);
158                let rule_str = "─".repeat(width.saturating_sub(0) as usize);
159                lines.push(Line::from(vec![Span::styled(
160                    rule_str,
161                    Style::default().fg(theme.rule),
162                )]));
163            }
164            Event::ListItem { bullet, number } => {
165                flush(&mut current_spans, &mut lines);
166                let prefix = if *bullet == '\0' {
167                    format!("{number}. ")
168                } else {
169                    format!("{bullet} ")
170                };
171                current_spans.push(Span::styled(prefix, Style::default().fg(theme.list_bullet)));
172            }
173            Event::Blank => {
174                flush(&mut current_spans, &mut lines);
175                lines.push(Line::default());
176            }
177            Event::Link { text, url } => {
178                let label = if text.is_empty() {
179                    url.clone()
180                } else {
181                    format!("{text} <{url}>")
182                };
183                let style = Style::default()
184                    .fg(theme.link)
185                    .add_modifier(Modifier::UNDERLINED);
186                for wrapped in wrap_str(&label, width as usize) {
187                    current_spans.push(Span::styled(wrapped, style));
188                }
189            }
190            Event::Text {
191                content,
192                bold,
193                italic,
194                code_span,
195            } => {
196                let fg = if *code_span {
197                    theme.code_span
198                } else if *bold {
199                    theme.bold
200                } else if *italic {
201                    theme.italic
202                } else {
203                    theme.text
204                };
205                let mut style = Style::default().fg(fg);
206                if *bold {
207                    style = style.add_modifier(Modifier::BOLD);
208                }
209                if *italic {
210                    style = style.add_modifier(Modifier::ITALIC);
211                }
212                if *code_span {
213                    style = style.add_modifier(Modifier::REVERSED);
214                }
215                // Newlines in text → flush + new line.
216                for (i, part) in content.split('\n').enumerate() {
217                    if i > 0 {
218                        flush(&mut current_spans, &mut lines);
219                    }
220                    if !part.is_empty() {
221                        for wrapped in wrap_str(part, width as usize) {
222                            current_spans.push(Span::styled(wrapped, style));
223                        }
224                    }
225                }
226            }
227            // Forward compat: ignore unknown variants from #[non_exhaustive] Event.
228            _ => {}
229        }
230    }
231
232    flush(&mut current_spans, &mut lines);
233
234    // Strip trailing blank lines.
235    while lines
236        .last()
237        .map(|l: &Line<'_>| l.spans.is_empty())
238        .unwrap_or(false)
239    {
240        lines.pop();
241    }
242
243    lines
244}
245
246/// Naive word-wrap: split `s` into chunks that fit in `width` columns.
247/// Falls back to hard-breaking if a single word exceeds `width`.
248fn wrap_str(s: &str, width: usize) -> Vec<String> {
249    if width == 0 || s.len() <= width {
250        return vec![s.to_string()];
251    }
252    let mut out = Vec::new();
253    let mut current = String::new();
254    for word in s.split_whitespace() {
255        let needed = if current.is_empty() {
256            word.len()
257        } else {
258            current.len() + 1 + word.len()
259        };
260        if needed > width && !current.is_empty() {
261            out.push(std::mem::take(&mut current));
262        }
263        if !current.is_empty() {
264            current.push(' ');
265        }
266        if word.len() > width {
267            // Hard-break long token.
268            for chunk in word.as_bytes().chunks(width) {
269                let s = String::from_utf8_lossy(chunk).to_string();
270                if current.len() + s.len() > width && !current.is_empty() {
271                    out.push(std::mem::take(&mut current));
272                }
273                current.push_str(&s);
274            }
275        } else {
276            current.push_str(word);
277        }
278    }
279    if !current.is_empty() {
280        out.push(current);
281    }
282    if out.is_empty() {
283        out.push(String::new());
284    }
285    out
286}
287
288// ── Tests ─────────────────────────────────────────────────────────────────────
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use hjkl_markdown::parse;
294
295    #[test]
296    fn smoke_empty() {
297        let lines = to_lines(&[], &MdTheme::default(), 80);
298        assert!(lines.is_empty());
299    }
300
301    #[test]
302    fn heading_produces_lines() {
303        let evs = parse("# Hello");
304        let lines = to_lines(&evs, &MdTheme::default(), 80);
305        assert!(!lines.is_empty());
306        let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
307        assert!(text.contains("Hello"), "heading text not found: {text:?}");
308    }
309
310    #[test]
311    fn code_block_lines() {
312        let evs = parse("```rust\nfn main() {}\n```");
313        let lines = to_lines(&evs, &MdTheme::default(), 80);
314        assert!(
315            lines
316                .iter()
317                .any(|l| l.spans.iter().any(|s| s.content.contains("fn main")))
318        );
319    }
320
321    #[test]
322    fn wrap_long_line() {
323        let chunks = wrap_str("hello world foo bar baz", 10);
324        for c in &chunks {
325            assert!(c.len() <= 10, "chunk too wide: {c:?}");
326        }
327    }
328
329    #[test]
330    fn default_theme_has_colors() {
331        let t = MdTheme::default();
332        assert!(matches!(t.text, Color::Rgb(_, _, _)));
333        assert!(matches!(t.heading1, Color::Rgb(_, _, _)));
334    }
335}