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