1use hjkl_markdown::Event;
19use ratatui::{
20 style::{Color, Modifier, Style},
21 text::{Line, Span},
22};
23
24#[derive(Debug, Clone)]
33#[non_exhaustive]
34pub struct MdTheme {
35 pub text: Color,
37 pub heading1: Color,
39 pub heading: Color,
41 pub code_span: Color,
43 pub code_block: Color,
45 pub link: Color,
47 pub list_bullet: Color,
49 pub bold: Color,
51 pub italic: Color,
53 pub rule: Color,
55}
56
57impl MdTheme {
58 #[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 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
105pub fn to_lines(events: &[Event], theme: &MdTheme, width: u16) -> Vec<Line<'static>> {
112 let mut lines: Vec<Line<'static>> = Vec::new();
113 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 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 _ => {}
229 }
230 }
231
232 flush(&mut current_spans, &mut lines);
233
234 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
246fn 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 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#[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}