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 match indent {
327 1 => Span::styled(
328 word.content(),
329 Style::default().fg(color_config().heading_fg_color),
330 ),
331 2 => Span::styled(
332 word.content(),
333 Style::default().fg(heading_colors().level_2),
334 ),
335 3 => Span::styled(
336 word.content(),
337 Style::default().fg(heading_colors().level_3),
338 ),
339 4 => Span::styled(
340 word.content(),
341 Style::default().fg(heading_colors().level_4),
342 ),
343 5 => Span::styled(
344 word.content(),
345 Style::default().fg(heading_colors().level_5),
346 ),
347 6 => Span::styled(
348 word.content(),
349 Style::default().fg(heading_colors().level_6),
350 ),
351 _ => Span::styled(
352 word.content(),
353 Style::default().fg(color_config().heading_fg_color),
354 ),
355 }
356}
357
358fn render_heading(area: Rect, buf: &mut Buffer, component: TextComponent) {
359 let indent = if let Some(meta) = component.meta_info().first() {
360 match meta.kind() {
361 WordType::MetaInfo(MetaData::HeadingLevel(e)) => e,
362 _ => 1,
363 }
364 } else {
365 1
366 };
367
368 let content: Vec<Span<'_>> = component
369 .content()
370 .iter()
371 .flatten()
372 .map(|c| style_heading(c, indent))
373 .collect();
374
375 let paragraph = match indent {
376 1 => Paragraph::new(Line::from(content))
377 .block(Block::default().style(Style::default().bg(color_config().heading_bg_color)))
378 .alignment(Alignment::Center),
379 _ => Paragraph::new(Line::from(content)),
380 };
381
382 paragraph.render(area, buf);
383}
384
385fn render_paragraph(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
386 let top = component
387 .scroll_offset()
388 .saturating_sub(component.y_offset());
389 let mut content = component.content_owned();
390 let content = match clip {
391 Clipping::Both => {
392 content.drain(0..top as usize);
393 content.drain(area.height as usize..);
394 content
395 }
396 Clipping::Upper => {
397 let len = content.len();
398 let height = area.height;
399 let offset = len - height as usize;
400 let mut content = content;
401 content.drain(0..offset);
402 content
403 }
404 Clipping::Lower => {
405 let mut content = content;
406 content.drain(area.height as usize..);
407 content
408 }
409 Clipping::None => content,
410 };
411
412 let lines = content
413 .iter()
414 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
415 .collect::<Vec<_>>();
416
417 let paragraph = Paragraph::new(lines);
418
419 paragraph.render(area, buf);
420}
421
422fn render_details_summary(area: Rect, buf: &mut Buffer, component: TextComponent, folded: bool) {
423 let focused = component.is_focused();
424 let mut style = Style::default().add_modifier(Modifier::BOLD);
425 if focused {
426 style = style
427 .fg(color_config().link_selected_fg_color)
428 .bg(color_config().link_selected_bg_color);
429 }
430 let marker = if folded { "▶ " } else { "▼ " };
431 let mut spans: Vec<Span> = vec![Span::styled(marker, style)];
432 for word in component.content_owned().into_iter().flatten() {
433 spans.push(Span::styled(word.content().to_string(), style));
434 }
435 Paragraph::new(Line::from(spans)).render(area, buf);
436}
437
438fn render_list(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
439 let top = component
440 .scroll_offset()
441 .saturating_sub(component.y_offset());
442 let mut content = component.content_owned();
443 let content = match clip {
444 Clipping::Both => {
445 content.drain(0..top as usize);
446 content.drain(area.height as usize..);
447 content
448 }
449 Clipping::Upper => {
450 let len = content.len();
451 let height = area.height;
452 let offset = len - height as usize;
453 let mut content = content;
454 content.drain(0..offset);
455 content
456 }
457 Clipping::Lower => {
458 let mut content = content;
459 content.drain(area.height as usize..);
460 content
461 }
462 Clipping::None => content,
463 };
464 let content: Vec<ListItem<'_>> = content
465 .iter()
466 .map(|c| -> ListItem<'_> {
467 ListItem::new(Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
468 })
469 .collect();
470
471 let list = List::new(content);
472 list.render(area, buf);
473}
474
475fn render_code_block(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
476 let mut content = component
477 .content()
478 .iter()
479 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
480 .collect::<Vec<_>>();
481
482 let max_width = cmp::max(
483 component
484 .meta_info()
485 .iter()
486 .find_map(|f| match f.kind() {
487 WordType::MetaInfo(MetaData::LineLength(len)) => Some(len),
488 _ => None,
489 })
490 .unwrap_or(area.width)
491 + 2,
492 area.width,
493 );
494
495 match clip {
496 Clipping::Both => {
497 let top = component.scroll_offset() - component.y_offset();
498 content.drain(0..top as usize);
499 content.drain(area.height as usize..);
500 }
501 Clipping::Upper => {
502 let len = content.len();
503 let height = area.height;
504 let offset = len - height as usize;
505 content.drain(0..offset);
507 }
508 Clipping::Lower => {
509 content.drain(area.height as usize..);
510 }
511 Clipping::None => (),
512 }
513
514 let block = Block::default().style(Style::default().bg(color_config().code_block_bg_color));
515
516 let area = Rect {
517 width: max_width,
518 ..area
519 };
520
521 block.render(area, buf);
522
523 let area = if let Some(word) = component.meta_info().first()
524 && matches!(word.content(), "mermaid")
525 {
526 Rect {
527 x: area.x + 1,
528 width: buf.area().width,
529 ..area
530 }
531 } else {
532 Rect {
533 x: area.x + 1,
534 width: area.width - 1,
535 ..area
536 }
537 };
538
539 let paragraph = Paragraph::new(content);
540
541 paragraph.render(area, buf);
542}
543
544fn render_table(
545 area: Rect,
546 buf: &mut Buffer,
547 component: TextComponent,
548 clip: Clipping,
549 widths: Vec<u16>,
550 heights: Vec<u16>,
551) {
552 let column_count = widths.len();
553
554 if column_count == 0 {
555 Paragraph::new(Line::from("Malformed table").fg(Color::Red)).render(area, buf);
556 return;
557 }
558
559 let top = component
560 .scroll_offset()
561 .saturating_sub(component.y_offset());
562
563 let mut lines = build_table_lines(component.content(), &widths, &heights);
564
565 let lines = match clip {
566 Clipping::Both => {
567 lines.drain(0..top as usize);
568 lines.drain(area.height as usize..);
569 lines
570 }
571 Clipping::Upper => {
572 let offset = lines.len().saturating_sub(area.height as usize);
573 lines.drain(0..offset);
574 lines
575 }
576 Clipping::Lower => {
577 lines.drain(area.height as usize..);
578 lines
579 }
580 Clipping::None => lines,
581 };
582
583 Paragraph::new(lines).render(area, buf);
584}
585
586fn render_task(
587 area: Rect,
588 buf: &mut Buffer,
589 component: TextComponent,
590 clip: Clipping,
591 meta_info: &Word,
592) {
593 const CHECKBOX: &str = "✅ ";
594 const UNCHECKED: &str = "❌ ";
595
596 let checkbox = if meta_info.content() == "- [ ] " {
597 UNCHECKED
598 } else {
599 CHECKBOX
600 };
601
602 let paragraph = Paragraph::new(checkbox);
603
604 paragraph.render(area, buf);
605
606 let area = Rect {
607 x: area.x + 4,
608 width: area.width - 4,
609 ..area
610 };
611
612 let top = component
613 .scroll_offset()
614 .saturating_sub(component.y_offset());
615
616 let mut content = component.content_owned();
617
618 let content = match clip {
619 Clipping::Both => {
620 content.drain(0..top as usize);
621 content.drain(area.height as usize..);
622 content
623 }
624 Clipping::Upper => {
625 let len = content.len();
626 let height = area.height;
627 let offset = len - height as usize;
628 let mut content = content;
629 content.drain(0..offset);
630 content
631 }
632 Clipping::Lower => {
633 let mut content = content;
634 content.drain(area.height as usize..);
635 content
636 }
637 Clipping::None => content,
638 };
639
640 let lines = content
641 .iter()
642 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
643 .collect::<Vec<_>>();
644
645 let paragraph = Paragraph::new(lines);
646
647 paragraph.render(area, buf);
648}
649
650fn render_horizontal_separator(area: Rect, buf: &mut Buffer) {
651 let paragraph = Paragraph::new(Line::from(vec![Span::raw(
652 "\u{2014}".repeat(GENERAL_CONFIG.width.into()),
653 )]));
654
655 paragraph.render(area, buf);
656}