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 match clip {
464 Clipping::Both => {
465 let top = component.scroll_offset() - component.y_offset();
466 content.drain(0..top as usize);
467 content.drain(area.height as usize..);
468 }
469 Clipping::Upper => {
470 let len = content.len();
471 let height = area.height;
472 let offset = len - height as usize;
473 content.drain(0..offset);
475 }
476 Clipping::Lower => {
477 content.drain(area.height as usize..);
478 }
479 Clipping::None => (),
480 }
481
482 let block = Block::default().style(Style::default().bg(color_config().code_block_bg_color));
483
484 block.render(area, buf);
485
486 let area = Rect {
487 x: area.x + 1,
488 width: area.width - 1,
489 ..area
490 };
491
492 let paragraph = Paragraph::new(content);
493
494 paragraph.render(area, buf);
495}
496
497fn render_table(
498 area: Rect,
499 buf: &mut Buffer,
500 component: TextComponent,
501 clip: Clipping,
502 widths: Vec<u16>,
503 heights: Vec<u16>,
504) {
505 let column_count = widths.len();
506
507 if column_count == 0 {
508 Paragraph::new(Line::from("Malformed table").fg(Color::Red)).render(area, buf);
509 return;
510 }
511
512 let top = component
513 .scroll_offset()
514 .saturating_sub(component.y_offset());
515
516 let mut lines = build_table_lines(component.content(), &widths, &heights);
517
518 let lines = match clip {
519 Clipping::Both => {
520 lines.drain(0..top as usize);
521 lines.drain(area.height as usize..);
522 lines
523 }
524 Clipping::Upper => {
525 let offset = lines.len().saturating_sub(area.height as usize);
526 lines.drain(0..offset);
527 lines
528 }
529 Clipping::Lower => {
530 lines.drain(area.height as usize..);
531 lines
532 }
533 Clipping::None => lines,
534 };
535
536 Paragraph::new(lines).render(area, buf);
537}
538
539fn render_task(
540 area: Rect,
541 buf: &mut Buffer,
542 component: TextComponent,
543 clip: Clipping,
544 meta_info: &Word,
545) {
546 const CHECKBOX: &str = "✅ ";
547 const UNCHECKED: &str = "❌ ";
548
549 let checkbox = if meta_info.content() == "- [ ] " {
550 UNCHECKED
551 } else {
552 CHECKBOX
553 };
554
555 let paragraph = Paragraph::new(checkbox);
556
557 paragraph.render(area, buf);
558
559 let area = Rect {
560 x: area.x + 4,
561 width: area.width - 4,
562 ..area
563 };
564
565 let top = component
566 .scroll_offset()
567 .saturating_sub(component.y_offset());
568
569 let mut content = component.content_owned();
570
571 let content = match clip {
572 Clipping::Both => {
573 content.drain(0..top as usize);
574 content.drain(area.height as usize..);
575 content
576 }
577 Clipping::Upper => {
578 let len = content.len();
579 let height = area.height;
580 let offset = len - height as usize;
581 let mut content = content;
582 content.drain(0..offset);
583 content
584 }
585 Clipping::Lower => {
586 let mut content = content;
587 content.drain(area.height as usize..);
588 content
589 }
590 Clipping::None => content,
591 };
592
593 let lines = content
594 .iter()
595 .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
596 .collect::<Vec<_>>();
597
598 let paragraph = Paragraph::new(lines);
599
600 paragraph.render(area, buf);
601}
602
603fn render_horizontal_separator(area: Rect, buf: &mut Buffer) {
604 let paragraph = Paragraph::new(Line::from(vec![Span::raw(
605 "\u{2014}".repeat(GENERAL_CONFIG.width.into()),
606 )]));
607
608 paragraph.render(area, buf);
609}