1use std::borrow::Cow;
2use std::cmp;
3
4use ratatui::{
5 buffer::Buffer,
6 layout::{Alignment, Rect},
7 style::{Color, Modifier, Style, Stylize},
8 text::{Line, Span},
9 widgets::{Block, List, ListItem, Paragraph, Widget},
10};
11
12use crate::{
13 nodes::{
14 textcomponent::{
15 TABLE_CELL_PADDING, TextComponent, TextNode, content_entry_len, word_wrapping,
16 },
17 word::{MetaData, Word, WordType},
18 },
19 util::{
20 colors::{color_config, heading_colors},
21 general::GENERAL_CONFIG,
22 },
23};
24
25fn clips_upper_bound(_area: Rect, component: &TextComponent) -> bool {
26 component.scroll_offset() > component.y_offset()
27}
28
29fn clips_lower_bound(area: Rect, component: &TextComponent) -> bool {
30 (component.y_offset() + component.height()).saturating_sub(component.scroll_offset())
31 > area.height
32}
33
34enum Clipping {
35 Both,
36 Upper,
37 Lower,
38 None,
39}
40
41impl Widget for TextComponent {
42 fn render(self, area: Rect, buf: &mut Buffer) {
43 let kind = self.kind();
44
45 let y = self.y_offset().saturating_sub(self.scroll_offset());
46
47 let clips = if clips_upper_bound(area, &self) && clips_lower_bound(area, &self) {
48 Clipping::Both
49 } else if clips_upper_bound(area, &self) {
50 Clipping::Upper
51 } else if clips_lower_bound(area, &self) {
52 Clipping::Lower
53 } else {
54 Clipping::None
55 };
56
57 let height = match clips {
58 Clipping::Both => {
59 let new_y = self.y_offset().saturating_sub(self.scroll_offset());
60 let new_height = new_y;
61 cmp::min(self.height(), area.height.saturating_sub(new_height))
62 }
63
64 Clipping::Upper => cmp::min(
65 self.height(),
66 (self.height() + self.y_offset()).saturating_sub(self.scroll_offset()),
67 ),
68 Clipping::Lower => {
69 let new_y = self.y_offset() - self.scroll_offset();
70 let new_height = new_y;
71 cmp::min(self.height(), area.height.saturating_sub(new_height))
72 }
73 Clipping::None => self.height(),
74 };
75
76 let meta_info = self
77 .meta_info()
78 .to_owned()
79 .first()
80 .cloned()
81 .unwrap_or_else(|| Word::new(String::new(), WordType::Normal));
82
83 let area = Rect { height, y, ..area };
84
85 match kind {
86 TextNode::Paragraph => render_paragraph(area, buf, self, clips),
87 TextNode::Heading => render_heading(area, buf, self),
88 TextNode::Task => render_task(area, buf, self, clips, &meta_info),
89 TextNode::List => render_list(area, buf, self, clips),
90 TextNode::CodeBlock => render_code_block(area, buf, self, clips),
91 TextNode::Table(widths, heights) => {
92 render_table(area, buf, self, clips, widths, heights);
93 }
94 TextNode::Quote => render_quote(area, buf, self, clips),
95 TextNode::LineBreak => (),
96 TextNode::HorizontalSeparator => render_horizontal_separator(area, buf),
97 TextNode::Image => todo!(),
98 TextNode::Footnote => (),
99 TextNode::DetailsSummary { folded, .. } => {
100 render_details_summary(area, buf, self, folded);
101 }
102 }
103 }
104}
105
106fn style_word_content<'a>(word: &Word, content: impl Into<Cow<'a, str>>) -> Span<'a> {
107 match word.kind() {
108 WordType::MetaInfo(_) | WordType::LinkData | WordType::FootnoteData => unreachable!(),
109 WordType::Selected => Span::styled(
110 content,
111 Style::default()
112 .fg(color_config().link_selected_fg_color)
113 .bg(color_config().link_selected_bg_color),
114 ),
115 WordType::Normal => Span::raw(content),
116 WordType::Code => Span::styled(content, Style::default().fg(color_config().code_fg_color))
117 .bg(color_config().code_bg_color),
118 WordType::Link | WordType::FootnoteInline => {
119 Span::styled(content, Style::default().fg(color_config().link_color))
120 }
121 WordType::Italic => Span::styled(
122 content,
123 Style::default().fg(color_config().italic_color).italic(),
124 ),
125 WordType::Bold => Span::styled(
126 content,
127 Style::default().fg(color_config().bold_color).bold(),
128 ),
129 WordType::Strikethrough | WordType::Footnote => Span::styled(
130 content,
131 Style::default()
132 .fg(color_config().striketrough_color)
133 .add_modifier(Modifier::CROSSED_OUT),
134 ),
135 WordType::White => Span::styled(content, Style::default().fg(Color::White)),
136 WordType::ListMarker => Span::styled(content, Style::default().fg(Color::White)),
137 WordType::BoldItalic => Span::styled(
138 content,
139 Style::default()
140 .fg(color_config().bold_italic_color)
141 .add_modifier(Modifier::BOLD)
142 .add_modifier(Modifier::ITALIC),
143 ),
144 WordType::CodeBlock(e) => Span::styled(content, e),
145 }
146}
147
148fn style_word(word: &Word) -> Span<'_> {
149 style_word_content(word, word.content())
150}
151
152fn style_word_owned(word: &Word) -> Span<'static> {
153 style_word_content(word, word.content().to_owned())
154}
155
156fn wrap_table_cell(entry: &[Word], width: u16) -> Vec<Vec<Word>> {
157 if width == 0 {
158 return vec![Vec::new()];
159 }
160
161 word_wrapping(entry.iter(), width as usize, true)
162}
163
164fn table_border_line(
165 widths: &[u16],
166 left: &'static str,
167 middle: &'static str,
168 right: &'static str,
169) -> Line<'static> {
170 let mut spans = vec![Span::raw(left)];
171
172 for (column_i, width) in widths.iter().enumerate() {
173 spans.push(Span::raw(
174 "─".repeat((width + TABLE_CELL_PADDING * 2) as usize),
175 ));
176 spans.push(Span::raw(if column_i + 1 == widths.len() {
177 right
178 } else {
179 middle
180 }));
181 }
182
183 Line::from(spans)
184}
185
186fn build_table_row_lines(
187 row: &[Vec<Word>],
188 widths: &[u16],
189 row_height: u16,
190 row_style: Option<Style>,
191) -> Vec<Line<'static>> {
192 let wrapped_cells = row
193 .iter()
194 .zip(widths.iter())
195 .map(|(entry, width)| wrap_table_cell(entry, *width))
196 .collect::<Vec<_>>();
197
198 (0..row_height as usize)
199 .map(|line_i| {
200 let mut spans = vec![Span::raw("│")];
201
202 for (column_i, cell_lines) in wrapped_cells.iter().enumerate() {
203 spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING as usize)));
204
205 if let Some(words) = cell_lines.get(line_i) {
206 spans.extend(words.iter().map(style_word_owned));
207
208 let padding = widths[column_i] as usize - content_entry_len(words);
209 if padding > 0 {
210 spans.push(Span::raw(" ".repeat(padding)));
211 }
212 } else {
213 spans.push(Span::raw(" ".repeat(widths[column_i] as usize)));
214 }
215
216 spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING as usize)));
217 spans.push(Span::raw("│"));
218 }
219
220 let line = Line::from(spans);
221 if let Some(style) = row_style {
222 line.patch_style(style)
223 } else {
224 line
225 }
226 })
227 .collect()
228}
229
230fn build_table_lines(content: &[Vec<Word>], widths: &[u16], heights: &[u16]) -> Vec<Line<'static>> {
231 let column_count = widths.len();
232 let header_style = Style::default()
233 .fg(color_config().table_header_fg_color)
234 .bg(color_config().table_header_bg_color);
235
236 let mut lines = vec![table_border_line(widths, "╭", "┬", "╮")];
237
238 for (row_i, row) in content.chunks(column_count).enumerate() {
239 let row_style = (row_i == 0).then_some(header_style);
240 lines.extend(build_table_row_lines(
241 row,
242 widths,
243 heights[row_i],
244 row_style,
245 ));
246
247 if row_i == 0 {
248 lines.push(table_border_line(widths, "├", "┼", "┤"));
249 }
250 }
251
252 lines.push(table_border_line(widths, "╰", "┴", "╯"));
253 lines
254}
255
256fn render_quote(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
257 let top = component
258 .scroll_offset()
259 .saturating_sub(component.y_offset());
260
261 let meta = component.meta_info().to_owned();
262
263 let mut content = component.content_owned();
264 let content = match clip {
265 Clipping::Both => {
266 content.drain(0..top as usize);
267 content.drain(area.height as usize..);
268 content
269 }
270 Clipping::Upper => {
271 let len = content.len();
272 let height = area.height;
273 let offset = len - height as usize;
274 let mut content = content;
275 content.drain(0..offset);
276 content
277 }
278 Clipping::Lower => {
279 let mut content = content;
280 content.drain(area.height as usize..);
281 content
282 }
283 Clipping::None => content,
284 };
285
286 let lines = content
287 .iter()
288 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
289 .collect::<Vec<_>>();
290
291 let bar_color = if let Some(meta) = meta.first() {
292 meta.content()
293 .split_whitespace()
294 .next()
295 .map(str::to_lowercase)
296 .map_or(color_config().quote_bg_color, |c| match c.as_str() {
297 "[!tip]" => color_config().quote_tip,
298 "[!warning]" => color_config().quote_warning,
299 "[!caution]" => color_config().quote_caution,
300 "[!important]" => color_config().quote_important,
301 "[!note]" => color_config().quote_note,
302 _ => color_config().quote_default,
303 })
304 } else {
305 Color::White
306 };
307 let vertical_marker = Span::styled("\u{2588}", Style::default().fg(bar_color));
308
309 let marker_paragraph = Paragraph::new(vec![Line::from(vertical_marker); content.len()])
310 .bg(color_config().quote_bg_color);
311 marker_paragraph.render(area, buf);
312
313 let paragraph = Paragraph::new(lines)
314 .block(Block::default().style(Style::default().bg(color_config().quote_bg_color)));
315
316 let area = Rect {
317 x: area.x + 1,
318 width: cmp::min(area.width, GENERAL_CONFIG.width) - 1,
319 ..area
320 };
321
322 paragraph.render(area, buf);
323}
324
325fn style_heading(word: &Word, indent: u8) -> Span<'_> {
326 if word.kind() == WordType::Code {
327 return style_word_content(word, word.content());
328 }
329 match indent {
330 1 => Span::styled(
331 word.content(),
332 Style::default().fg(color_config().heading_fg_color),
333 ),
334 2 => Span::styled(
335 word.content(),
336 Style::default().fg(heading_colors().level_2),
337 ),
338 3 => Span::styled(
339 word.content(),
340 Style::default().fg(heading_colors().level_3),
341 ),
342 4 => Span::styled(
343 word.content(),
344 Style::default().fg(heading_colors().level_4),
345 ),
346 5 => Span::styled(
347 word.content(),
348 Style::default().fg(heading_colors().level_5),
349 ),
350 6 => Span::styled(
351 word.content(),
352 Style::default().fg(heading_colors().level_6),
353 ),
354 _ => Span::styled(
355 word.content(),
356 Style::default().fg(color_config().heading_fg_color),
357 ),
358 }
359}
360
361fn render_heading(area: Rect, buf: &mut Buffer, component: TextComponent) {
362 let indent = if let Some(meta) = component.meta_info().first() {
363 match meta.kind() {
364 WordType::MetaInfo(MetaData::HeadingLevel(e)) => e,
365 _ => 1,
366 }
367 } else {
368 1
369 };
370
371 let content: Vec<Span<'_>> = component
372 .content()
373 .iter()
374 .flatten()
375 .map(|c| style_heading(c, indent))
376 .collect();
377
378 let paragraph = match indent {
379 1 => Paragraph::new(Line::from(content))
380 .block(Block::default().style(Style::default().bg(color_config().heading_bg_color)))
381 .alignment(Alignment::Center),
382 _ => Paragraph::new(Line::from(content)),
383 };
384
385 paragraph.render(area, buf);
386}
387
388fn render_paragraph(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
389 let top = component
390 .scroll_offset()
391 .saturating_sub(component.y_offset());
392 let mut content = component.content_owned();
393 let content = match clip {
394 Clipping::Both => {
395 content.drain(0..top as usize);
396 content.drain(area.height as usize..);
397 content
398 }
399 Clipping::Upper => {
400 let len = content.len();
401 let height = area.height;
402 let offset = len - height as usize;
403 let mut content = content;
404 content.drain(0..offset);
405 content
406 }
407 Clipping::Lower => {
408 let mut content = content;
409 content.drain(area.height as usize..);
410 content
411 }
412 Clipping::None => content,
413 };
414
415 let lines = content
416 .iter()
417 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
418 .collect::<Vec<_>>();
419
420 let paragraph = Paragraph::new(lines);
421
422 paragraph.render(area, buf);
423}
424
425fn render_details_summary(area: Rect, buf: &mut Buffer, component: TextComponent, folded: bool) {
426 let focused = component.is_focused();
427 let mut style = Style::default().add_modifier(Modifier::BOLD);
428 if focused {
429 style = style
430 .fg(color_config().link_selected_fg_color)
431 .bg(color_config().link_selected_bg_color);
432 }
433 let marker = if folded { "▶ " } else { "▼ " };
434 let mut spans: Vec<Span> = vec![Span::styled(marker, style)];
435 for word in component.content_owned().into_iter().flatten() {
436 spans.push(Span::styled(word.content().to_string(), style));
437 }
438 Paragraph::new(Line::from(spans)).render(area, buf);
439}
440
441fn render_list(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
442 let top = component
443 .scroll_offset()
444 .saturating_sub(component.y_offset());
445 let mut content = component.content_owned();
446 let content = match clip {
447 Clipping::Both => {
448 content.drain(0..top as usize);
449 content.drain(area.height as usize..);
450 content
451 }
452 Clipping::Upper => {
453 let len = content.len();
454 let height = area.height;
455 let offset = len - height as usize;
456 let mut content = content;
457 content.drain(0..offset);
458 content
459 }
460 Clipping::Lower => {
461 let mut content = content;
462 content.drain(area.height as usize..);
463 content
464 }
465 Clipping::None => content,
466 };
467 let content: Vec<ListItem<'_>> = content
468 .iter()
469 .map(|c| -> ListItem<'_> {
470 ListItem::new(Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
471 })
472 .collect();
473
474 let list = List::new(content);
475 list.render(area, buf);
476}
477
478fn render_code_block(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
479 let mut content = component
480 .content()
481 .iter()
482 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
483 .collect::<Vec<_>>();
484
485 let max_width = cmp::max(
486 component
487 .meta_info()
488 .iter()
489 .find_map(|f| match f.kind() {
490 WordType::MetaInfo(MetaData::LineLength(len)) => Some(len),
491 _ => None,
492 })
493 .unwrap_or(area.width)
494 + 2,
495 area.width,
496 );
497
498 match clip {
499 Clipping::Both => {
500 let top = component.scroll_offset() - component.y_offset();
501 content.drain(0..top as usize);
502 content.drain(area.height as usize..);
503 }
504 Clipping::Upper => {
505 let len = content.len();
506 let height = area.height;
507 let offset = len - height as usize;
508 content.drain(0..offset);
510 }
511 Clipping::Lower => {
512 content.drain(area.height as usize..);
513 }
514 Clipping::None => (),
515 }
516
517 let block = Block::default().style(Style::default().bg(color_config().code_block_bg_color));
518
519 let area = Rect {
520 width: max_width,
521 ..area
522 };
523
524 block.render(area, buf);
525
526 let area = if let Some(word) = component.meta_info().first()
527 && matches!(word.content(), "mermaid")
528 {
529 Rect {
530 x: area.x + 1,
531 width: buf.area().width,
532 ..area
533 }
534 } else {
535 Rect {
536 x: area.x + 1,
537 width: area.width - 1,
538 ..area
539 }
540 };
541
542 let paragraph = Paragraph::new(content);
543
544 paragraph.render(area, buf);
545}
546
547fn render_table(
548 area: Rect,
549 buf: &mut Buffer,
550 component: TextComponent,
551 clip: Clipping,
552 widths: Vec<u16>,
553 heights: Vec<u16>,
554) {
555 let column_count = widths.len();
556
557 if column_count == 0 {
558 Paragraph::new(Line::from("Malformed table").fg(Color::Red)).render(area, buf);
559 return;
560 }
561
562 let top = component
563 .scroll_offset()
564 .saturating_sub(component.y_offset());
565
566 let mut lines = build_table_lines(component.content(), &widths, &heights);
567
568 let lines = match clip {
569 Clipping::Both => {
570 lines.drain(0..top as usize);
571 lines.drain(area.height as usize..);
572 lines
573 }
574 Clipping::Upper => {
575 let offset = lines.len().saturating_sub(area.height as usize);
576 lines.drain(0..offset);
577 lines
578 }
579 Clipping::Lower => {
580 lines.drain(area.height as usize..);
581 lines
582 }
583 Clipping::None => lines,
584 };
585
586 Paragraph::new(lines).render(area, buf);
587}
588
589fn render_task(
590 area: Rect,
591 buf: &mut Buffer,
592 component: TextComponent,
593 clip: Clipping,
594 meta_info: &Word,
595) {
596 const CHECKBOX: &str = "✅ ";
597 const UNCHECKED: &str = "❌ ";
598
599 let checkbox = if meta_info.content() == "- [ ] " {
600 UNCHECKED
601 } else {
602 CHECKBOX
603 };
604
605 let paragraph = Paragraph::new(checkbox);
606
607 paragraph.render(area, buf);
608
609 let area = Rect {
610 x: area.x + 4,
611 width: area.width - 4,
612 ..area
613 };
614
615 let top = component
616 .scroll_offset()
617 .saturating_sub(component.y_offset());
618
619 let mut content = component.content_owned();
620
621 let content = match clip {
622 Clipping::Both => {
623 content.drain(0..top as usize);
624 content.drain(area.height as usize..);
625 content
626 }
627 Clipping::Upper => {
628 let len = content.len();
629 let height = area.height;
630 let offset = len - height as usize;
631 let mut content = content;
632 content.drain(0..offset);
633 content
634 }
635 Clipping::Lower => {
636 let mut content = content;
637 content.drain(area.height as usize..);
638 content
639 }
640 Clipping::None => content,
641 };
642
643 let lines = content
644 .iter()
645 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
646 .collect::<Vec<_>>();
647
648 let paragraph = Paragraph::new(lines);
649
650 paragraph.render(area, buf);
651}
652
653fn render_horizontal_separator(area: Rect, buf: &mut Buffer) {
654 let paragraph = Paragraph::new(Line::from(vec![Span::raw(
655 "\u{2014}".repeat(GENERAL_CONFIG.width.into()),
656 )]));
657
658 paragraph.render(area, buf);
659}