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