1use ratatui::{
2 style::{Color, Modifier, Style},
3 text::Span,
4};
5
6use super::Renderer;
7use crate::document::{DocumentBuffer, LineType, TableAlignment};
8
9pub struct MarkdownRenderer {
11 heading1_style: Style,
13 heading2_style: Style,
14 heading3_style: Style,
15 heading4_style: Style,
16 heading_other_style: Style,
17 code_block_style: Style,
18 quote_style: Style,
19 quote_border_style: Style,
20 table_border_style: Style,
22 table_header_style: Style,
23 table_cell_style: Style,
24}
25
26impl MarkdownRenderer {
27 pub fn new() -> Self {
28 Self {
29 heading1_style: Style::default()
30 .fg(Color::Cyan)
31 .add_modifier(Modifier::BOLD),
32 heading2_style: Style::default()
33 .fg(Color::Blue)
34 .add_modifier(Modifier::BOLD),
35 heading3_style: Style::default()
36 .fg(Color::Magenta)
37 .add_modifier(Modifier::BOLD),
38 heading4_style: Style::default()
39 .fg(Color::Yellow)
40 .add_modifier(Modifier::BOLD),
41 heading_other_style: Style::default().add_modifier(Modifier::BOLD),
42 code_block_style: Style::default().fg(Color::Green),
43 quote_style: Style::default().fg(Color::Gray),
44 quote_border_style: Style::default().fg(Color::DarkGray),
45 table_border_style: Style::default().fg(Color::DarkGray),
46 table_header_style: Style::default()
47 .fg(Color::Cyan)
48 .add_modifier(Modifier::BOLD),
49 table_cell_style: Style::default(),
50 }
51 }
52
53 pub fn render_line_with_type(
55 &self,
56 content: &str,
57 line_type: &LineType,
58 is_current: bool,
59 ) -> Vec<Span<'_>> {
60 if is_current {
61 vec![Span::styled(content.to_string(), Style::default())]
63 } else {
64 self.render_rich(content, line_type)
66 }
67 }
68
69 fn render_rich(&self, content: &str, line_type: &LineType) -> Vec<Span<'_>> {
71 match line_type {
72 LineType::Heading(level) => self.render_heading_line(content, *level),
73 LineType::ListItem => self.render_list_item(content, false, false),
74 LineType::OrderedListItem => self.render_list_item(content, true, false),
75 LineType::TaskListItem(checked) => self.render_list_item(content, false, *checked),
76 LineType::Blockquote => self.render_blockquote_line(content),
77 LineType::CodeFence(lang) => self.render_code_fence(content, lang.as_deref()),
78 LineType::InCode => self.render_code_content(content),
79 LineType::HorizontalRule => vec![Span::styled(
80 "─".repeat(80),
81 Style::default().fg(Color::DarkGray),
82 )],
83 LineType::Image(alt_text, path) => self.render_image(alt_text, path),
84 LineType::TableHeader(cells) => {
85 let widths: Vec<usize> = cells.iter().map(|c| c.chars().count()).collect();
87 let alignments = vec![TableAlignment::Left; cells.len()];
88 self.render_table_header(cells, &widths, &alignments)
89 }
90 LineType::TableSeparator(alignments) => {
91 let widths = vec![10; alignments.len()];
93 self.render_table_separator(&widths, alignments)
94 }
95 LineType::TableRow(cells) => {
96 let widths: Vec<usize> = cells.iter().map(|c| c.chars().count()).collect();
98 let alignments = vec![TableAlignment::Left; cells.len()];
99 self.render_table_row(cells, &widths, &alignments)
100 }
101 LineType::FrontMatterDelimiter => self.render_front_matter_delimiter(),
102 LineType::FrontMatterContent => self.render_front_matter_content(content),
103 LineType::Text => self.render_text_line(content),
104 }
105 }
106
107 fn render_heading_line(&self, content: &str, level: usize) -> Vec<Span<'_>> {
109 self.render_heading_line_with_width(content, level, None)
110 }
111
112 pub fn render_heading_line_with_width(
114 &self,
115 content: &str,
116 level: usize,
117 terminal_width: Option<usize>,
118 ) -> Vec<Span<'_>> {
119 let style = match level {
120 1 => self.heading1_style,
121 2 => self.heading2_style,
122 3 => self.heading3_style,
123 4 => self.heading4_style,
124 _ => self.heading_other_style,
125 };
126
127 let text = content.trim_start_matches('#').trim();
129
130 let prefix = match level {
132 1 => "# ", 2 => "## ", 3 => "### ", 4 => "#### ", 5 => "##### ", _ => "###### ", };
139
140 let bg_color = match level {
142 1 => Color::Rgb(0, 60, 80), 2 => Color::Rgb(0, 40, 100), 3 => Color::Rgb(60, 0, 80), 4 => Color::Rgb(80, 60, 0), _ => Color::Rgb(40, 40, 40), };
148 let style_with_bg = style.bg(bg_color);
149
150 let heading_text = format!("{}{}", prefix, text);
151
152 if let Some(width) = terminal_width {
154 let text_len = heading_text.chars().count();
155 if text_len < width {
156 let padding = " ".repeat(width - text_len);
157 vec![
158 Span::styled(heading_text, style_with_bg),
159 Span::styled(padding, Style::default().bg(bg_color)),
160 ]
161 } else {
162 vec![Span::styled(heading_text, style_with_bg)]
163 }
164 } else {
165 vec![Span::styled(heading_text, style_with_bg)]
166 }
167 }
168
169 fn render_list_item(&self, content: &str, ordered: bool, task_checked: bool) -> Vec<Span<'_>> {
171 let trimmed = content.trim_start();
172
173 let indent_count = content.len() - trimmed.len();
175 let indent = " ".repeat(indent_count);
176
177 let (bullet, text) = if ordered {
179 if let Some(dot_pos) = trimmed.find(". ") {
181 let num = &trimmed[..dot_pos + 1];
182 let text = &trimmed[dot_pos + 2..];
183 (num.to_string(), text)
184 } else if let Some(paren_pos) = trimmed.find(") ") {
185 let num = &trimmed[..paren_pos + 1];
186 let text = &trimmed[paren_pos + 2..];
187 (num.to_string(), text)
188 } else {
189 ("1. ".to_string(), trimmed)
190 }
191 } else if trimmed.starts_with("- [") {
192 let bullet = if task_checked { "[✓] " } else { "[ ] " };
193 let text = trimmed
194 .strip_prefix("- [x] ")
195 .or_else(|| trimmed.strip_prefix("- [X] "))
196 .or_else(|| trimmed.strip_prefix("- [ ] "))
197 .unwrap_or(trimmed);
198 (bullet.to_string(), text)
199 } else {
200 let bullet = "• ";
201 let text = trimmed
202 .strip_prefix("- ")
203 .or_else(|| trimmed.strip_prefix("* "))
204 .or_else(|| trimmed.strip_prefix("+ "))
205 .unwrap_or(trimmed);
206 (bullet.to_string(), text)
207 };
208
209 vec![
210 Span::raw(indent),
211 Span::styled(bullet, Style::default().fg(Color::Yellow)),
212 Span::raw(text.to_string()),
213 ]
214 }
215
216 fn render_blockquote_line(&self, content: &str) -> Vec<Span<'_>> {
218 let text = content.trim_start().strip_prefix("> ").unwrap_or(content);
219 vec![
220 Span::styled("▎ ", self.quote_border_style),
221 Span::styled(text.to_string(), self.quote_style),
222 ]
223 }
224
225 fn render_code_fence(&self, _content: &str, lang: Option<&str>) -> Vec<Span<'_>> {
227 if let Some(lang) = lang {
228 vec![Span::styled(
229 format!("╭─ {} ─╮", lang),
230 Style::default().fg(Color::DarkGray),
231 )]
232 } else {
233 vec![Span::styled(
234 "╭─ code ─╮",
235 Style::default().fg(Color::DarkGray),
236 )]
237 }
238 }
239
240 pub fn render_code_fence_start(&self, lang: Option<&str>) -> Vec<Span<'_>> {
242 if let Some(lang) = lang {
243 vec![Span::styled(
244 format!(
245 "╭─ {} ─────────────────────────────────────────────────────",
246 lang
247 ),
248 Style::default().fg(Color::DarkGray),
249 )]
250 } else {
251 vec![Span::styled(
252 "╭─ code ────────────────────────────────────────────────────",
253 Style::default().fg(Color::DarkGray),
254 )]
255 }
256 }
257
258 pub fn render_code_fence_end(&self) -> Vec<Span<'_>> {
260 vec![Span::styled(
261 "╰────────────────────────────────────────────────────────────",
262 Style::default().fg(Color::DarkGray),
263 )]
264 }
265
266 pub fn render_code_content(&self, content: &str) -> Vec<Span<'_>> {
268 vec![Span::styled(content.to_string(), self.code_block_style)]
269 }
270
271 pub fn render_source(&self, content: &str) -> Vec<Span<'_>> {
273 vec![Span::styled(content.to_string(), Style::default())]
274 }
275
276 fn render_text_line(&self, content: &str) -> Vec<Span<'_>> {
278 vec![Span::raw(content.to_string())]
281 }
282
283 pub fn render_image_with_info(
285 &self,
286 alt_text: &str,
287 path: &str,
288 dimensions: Option<(u32, u32)>,
289 ) -> Vec<Span<'_>> {
290 let mut spans = vec![
291 Span::styled("🖼️ ", Style::default().fg(Color::Cyan)),
292 Span::styled(
293 format!("[{}]", alt_text),
294 Style::default()
295 .fg(Color::Blue)
296 .add_modifier(Modifier::ITALIC),
297 ),
298 Span::styled(" ", Style::default()),
299 ];
300
301 if let Some((width, height)) = dimensions {
303 spans.push(Span::styled(
304 format!("{}x{} ", width, height),
305 Style::default().fg(Color::Yellow),
306 ));
307 }
308
309 spans.push(Span::styled(
310 path.to_string(),
311 Style::default()
312 .fg(Color::DarkGray)
313 .add_modifier(Modifier::UNDERLINED),
314 ));
315
316 spans
317 }
318
319 fn render_image(&self, alt_text: &str, path: &str) -> Vec<Span<'_>> {
321 vec![
322 Span::styled("🖼️ ", Style::default().fg(Color::Cyan)),
323 Span::styled(
324 format!("[{}]", alt_text),
325 Style::default()
326 .fg(Color::Blue)
327 .add_modifier(Modifier::ITALIC),
328 ),
329 Span::styled(" ", Style::default()),
330 Span::styled(
331 path.to_string(),
332 Style::default()
333 .fg(Color::DarkGray)
334 .add_modifier(Modifier::UNDERLINED),
335 ),
336 ]
337 }
338
339 pub fn render_table_header(
341 &self,
342 cells: &[String],
343 column_widths: &[usize],
344 alignments: &[TableAlignment],
345 ) -> Vec<Span<'_>> {
346 self.render_table_row_internal(cells, column_widths, alignments, true)
347 }
348
349 pub fn render_table_separator(
351 &self,
352 column_widths: &[usize],
353 alignments: &[TableAlignment],
354 ) -> Vec<Span<'_>> {
355 let mut result = String::new();
356 result.push('├');
357
358 for (i, &width) in column_widths.iter().enumerate() {
359 let left_colon = matches!(
360 alignments.get(i),
361 Some(TableAlignment::Left) | Some(TableAlignment::Center)
362 );
363 let right_colon = matches!(
364 alignments.get(i),
365 Some(TableAlignment::Right) | Some(TableAlignment::Center)
366 );
367
368 if left_colon {
369 result.push(':');
370 result.push_str(&"─".repeat(width.saturating_sub(1) + 1));
371 } else {
372 result.push_str(&"─".repeat(width + 2));
373 }
374
375 if right_colon && !left_colon {
376 result.pop();
378 result.push(':');
379 } else if right_colon && left_colon {
380 result.pop();
381 result.push(':');
382 }
383
384 if i < column_widths.len() - 1 {
385 result.push('┼');
386 }
387 }
388 result.push('┤');
389
390 vec![Span::styled(result, self.table_border_style)]
391 }
392
393 pub fn render_table_row(
395 &self,
396 cells: &[String],
397 column_widths: &[usize],
398 alignments: &[TableAlignment],
399 ) -> Vec<Span<'_>> {
400 self.render_table_row_internal(cells, column_widths, alignments, false)
401 }
402
403 fn render_table_row_internal(
405 &self,
406 cells: &[String],
407 column_widths: &[usize],
408 alignments: &[TableAlignment],
409 is_header: bool,
410 ) -> Vec<Span<'_>> {
411 let mut spans = Vec::new();
412
413 spans.push(Span::styled("│", self.table_border_style));
415
416 for (i, cell) in cells.iter().enumerate() {
417 let width = column_widths
418 .get(i)
419 .copied()
420 .unwrap_or(cell.chars().count());
421 let alignment = alignments.get(i).copied().unwrap_or(TableAlignment::Left);
422
423 let padded = Self::pad_cell(cell, width, alignment);
425
426 let style = if is_header {
427 self.table_header_style
428 } else {
429 self.table_cell_style
430 };
431
432 spans.push(Span::styled(format!(" {} ", padded), style));
433 spans.push(Span::styled("│", self.table_border_style));
434 }
435
436 spans
437 }
438
439 fn pad_cell(content: &str, width: usize, alignment: TableAlignment) -> String {
441 let content_width = content.chars().count();
442 if content_width >= width {
443 return content.to_string();
444 }
445
446 let padding = width - content_width;
447 match alignment {
448 TableAlignment::Left | TableAlignment::None => {
449 format!("{}{}", content, " ".repeat(padding))
450 }
451 TableAlignment::Right => {
452 format!("{}{}", " ".repeat(padding), content)
453 }
454 TableAlignment::Center => {
455 let left_pad = padding / 2;
456 let right_pad = padding - left_pad;
457 format!(
458 "{}{}{}",
459 " ".repeat(left_pad),
460 content,
461 " ".repeat(right_pad)
462 )
463 }
464 }
465 }
466
467 fn render_front_matter_delimiter(&self) -> Vec<Span<'_>> {
469 vec![Span::styled(
470 "─".repeat(60),
471 Style::default().fg(Color::Rgb(100, 100, 150)),
472 )]
473 }
474
475 fn render_front_matter_content(&self, content: &str) -> Vec<Span<'_>> {
477 let trimmed = content.trim_start();
479
480 if let Some(colon_pos) = trimmed.find(':') {
482 let key = &trimmed[..colon_pos];
483 let value = &trimmed[colon_pos..];
484
485 vec![
486 Span::styled(
487 key.to_string(),
488 Style::default()
489 .fg(Color::Rgb(150, 180, 200))
490 .add_modifier(Modifier::BOLD),
491 ),
492 Span::styled(
493 value.to_string(),
494 Style::default().fg(Color::Rgb(200, 200, 220)),
495 ),
496 ]
497 } else if trimmed.starts_with('-') {
498 vec![Span::styled(
500 content.to_string(),
501 Style::default().fg(Color::Rgb(180, 180, 200)),
502 )]
503 } else if trimmed.starts_with('#') {
504 vec![Span::styled(
506 content.to_string(),
507 Style::default()
508 .fg(Color::DarkGray)
509 .add_modifier(Modifier::ITALIC),
510 )]
511 } else {
512 vec![Span::styled(
514 content.to_string(),
515 Style::default().fg(Color::Rgb(180, 180, 200)),
516 )]
517 }
518 }
519
520 fn determine_line_type(
522 &self,
523 buffer: &DocumentBuffer,
524 line_idx: usize,
525 content: &str,
526 ) -> crate::document::LineType {
527 use crate::document::LineAnalyzer;
528
529 let line_type = LineAnalyzer::analyze_line(content);
530
531 if let crate::document::LineType::FrontMatterDelimiter = line_type {
533 return line_type;
534 }
535
536 if self.is_inside_front_matter(buffer, line_idx) {
538 return crate::document::LineType::FrontMatterContent;
539 }
540
541 line_type
542 }
543
544 fn is_inside_front_matter(&self, buffer: &DocumentBuffer, line_idx: usize) -> bool {
546 if line_idx == 0 {
548 return false;
549 }
550
551 if let Some(first_line) = buffer.line(0) {
553 let first_trimmed = first_line.trim();
554 if first_trimmed != "---" && first_trimmed != "+++" {
555 return false;
556 }
557 } else {
558 return false;
559 }
560
561 let mut delimiter_count = 0;
563 for i in 0..=line_idx {
564 if let Some(line) = buffer.line(i) {
565 let trimmed = line.trim();
566 if trimmed == "---" || trimmed == "+++" {
567 delimiter_count += 1;
568 }
569 }
570 }
571
572 delimiter_count == 1
574 }
575}
576
577impl Default for MarkdownRenderer {
578 fn default() -> Self {
579 Self::new()
580 }
581}
582
583impl Renderer for MarkdownRenderer {
584 fn render_line(
585 &self,
586 buffer: &DocumentBuffer,
587 line_idx: usize,
588 is_current_line: bool,
589 ) -> Vec<Span<'_>> {
590 let content = buffer.line(line_idx).unwrap_or("");
592
593 if is_current_line {
594 vec![Span::styled(content.to_string(), Style::default())]
596 } else {
597 let line_type = self.determine_line_type(buffer, line_idx, content);
599 self.render_rich(content, &line_type)
600 }
601 }
602
603 fn supports_wysiwyg(&self) -> bool {
604 true }
606}