1use std::{marker::PhantomData, ops::Range};
2
3use ratatui::{
4 buffer::Buffer,
5 layout::{Offset, Rect},
6 style::{Color, Style, Stylize},
7 text::{Line, Span},
8 widgets::{
9 Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
10 StatefulWidget, Widget,
11 },
12};
13use unicode_width::UnicodeWidthChar;
14
15use crate::note_editor::{
16 cursor::CursorWidget, state::NoteEditorState, viewport::Viewport, virtual_document::VirtualLine,
17};
18
19const SELECTION_STYLE: Style = Style::new().reversed();
20const YANK_FLASH_STYLE: Style = Style::new().bg(Color::LightCyan);
21
22fn render_highlight(
23 buf: &mut Buffer,
24 inner_area: Rect,
25 viewport: &Viewport,
26 lines: &[VirtualLine],
27 meta_len: usize,
28 range: &Range<usize>,
29 style: Style,
30) {
31 let viewport_top = viewport.top() as usize;
32 let horizontal_scroll = viewport.left();
33
34 let paint = |buf: &mut Buffer, col: u16, y: u16, width: u16| {
35 if col >= horizontal_scroll {
36 let cell = Rect::new(inner_area.x + col - horizontal_scroll, y, width.max(1), 1)
37 .intersection(inner_area);
38 buf.set_style(cell, style);
39 }
40 };
41
42 lines
43 .iter()
44 .enumerate()
45 .skip(viewport_top)
46 .take(inner_area.height as usize)
47 .filter(|(idx, _)| *idx >= meta_len)
48 .for_each(|(idx, line)| {
49 let y = inner_area.y + (idx - viewport_top) as u16;
50 let spans = line.virtual_spans();
51 let mut col = 0u16;
52
53 for (i, span) in spans.iter().enumerate() {
54 match span.source_range() {
55 Some(source_range) => {
56 span.char_indices().fold(col, |col, (byte_idx, ch)| {
57 let width = ch.width().unwrap_or(0) as u16;
58 if range.contains(&(source_range.start + byte_idx)) {
59 paint(buf, col, y, width);
60 }
61 col + width
62 });
63 }
64 None => {
69 let precedes_selection = spans[i + 1..]
70 .iter()
71 .find_map(|span| span.source_range())
72 .is_some_and(|next| range.contains(&next.start));
73 if precedes_selection {
74 paint(buf, col, y, span.width() as u16);
75 }
76 }
77 }
78 col += span.width() as u16;
79 }
80 });
81}
82
83#[derive(Default)]
84pub struct NoteEditor<'a>(pub PhantomData<&'a ()>);
85
86impl<'a> StatefulWidget for NoteEditor<'a> {
87 type State = NoteEditorState<'a>;
88
89 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
90 let theme = state.theme();
91 let active = state.active();
92 let pane = theme.note_editor;
93 let fallback = if active {
94 state.symbols.border_active
95 } else {
96 state.symbols.border_inactive
97 }
98 .into();
99 let border_line = pane.border_line(fallback);
100
101 let pending = state.pending_hint();
105 let footer: Vec<Span> = if pending.is_empty() {
106 Vec::new()
107 } else {
108 vec![format!(" {pending} ").fg(theme.accent).bold()]
109 };
110
111 let mut block = Block::new()
112 .borders(if border_line.is_some() {
113 pane.border_edges.to_borders()
114 } else {
115 Borders::NONE
116 })
117 .style(Style::new().fg(theme.text).bg(pane.background))
118 .border_style(Style::new().fg(pane.border(active)))
119 .title_bottom(footer)
120 .padding(Padding::horizontal(1));
121
122 if let Some(line) = border_line {
123 block = block.border_type(line);
124 }
125
126 let inner_area = block.inner(area);
127
128 state.resize_viewport(inner_area.as_size());
132
133 state.update_layout();
134
135 let mut lines = state.virtual_document.meta().to_vec();
136 lines.extend(state.virtual_document.lines().to_vec());
137
138 let visible_lines = lines
139 .iter()
140 .skip(state.viewport().top() as usize)
141 .take(state.viewport().bottom() as usize)
142 .cloned()
144 .map(|visual_line| visual_line.into())
145 .collect::<Vec<Line>>();
146
147 let rendered_lines_count = state.virtual_document.lines().len();
148 let meta_lines_count = state.virtual_document.meta().len();
149
150 Paragraph::new(visible_lines)
151 .scroll((0, state.viewport().left()))
152 .block(block)
153 .render(area, buf);
154
155 if let Some(range) = state.selection_range() {
156 render_highlight(
157 buf,
158 inner_area,
159 state.viewport(),
160 &lines,
161 meta_lines_count,
162 &range,
163 SELECTION_STYLE,
164 );
165 }
166
167 if let Some(range) = state.yank_flash_range() {
168 render_highlight(
169 buf,
170 inner_area,
171 state.viewport(),
172 &lines,
173 meta_lines_count,
174 &range,
175 YANK_FLASH_STYLE,
176 );
177 }
178
179 if !state.content.is_empty() || state.is_editing() {
180 CursorWidget::default()
181 .with_offset(Offset {
182 x: inner_area.x as i32,
183 y: inner_area.y as i32,
184 })
185 .with_meta_len(meta_lines_count as u16)
186 .with_theme(&theme)
187 .render(state.viewport().area(), buf, &mut state.cursor);
188 }
189
190 if !area.is_empty() && lines.len() as u16 > inner_area.bottom() {
191 let mut scroll_state =
192 ScrollbarState::new(rendered_lines_count).position(state.cursor.virtual_row());
193
194 Scrollbar::new(ScrollbarOrientation::VerticalRight).render(
195 area,
196 buf,
197 &mut scroll_state,
198 );
199 }
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use std::path::Path;
206
207 use crate::{
208 config::Symbols,
209 note_editor::state::{EditMode, SelectionMode, View},
210 };
211
212 use super::*;
213 use indoc::indoc;
214 use insta::assert_snapshot;
215 use ratatui::{backend::TestBackend, Terminal};
216
217 #[test]
218 fn test_rendered_markdown_view() {
219 let tests = [
220 indoc! { r#"## Headings
221
222 # This is a heading 1
223
224 ## This is a heading 2
225
226 ### This is a heading 3
227
228 #### This is a heading 4
229
230 ##### This is a heading 5
231
232 ###### This is a heading 6
233 "#},
234 indoc! { r#"## Quotes
235
236 You can quote text by adding a > symbols before the text.
237
238 > Human beings face ever more complex and urgent problems, and their effectiveness in dealing with these problems is a matter that is critical to the stability and continued progress of society.
239 >
240 > - Doug Engelbart, 1961
241 "#},
242 indoc! { r#"## Callout Blocks
243
244 > [!tip]
245 >
246 >You can turn your quote into a [callout](https://help.obsidian.md/Editing+and+formatting/Callouts) by adding `[!info]` as the first line in a quote.
247 "#},
248 indoc! { r#"## Deep Quotes
249
250 You can have deeper levels of quotes by adding a > symbols before the text inside the block quote.
251
252 > Regular thoughts
253 >
254 > > Deeper thoughts
255 > >
256 > > > Very deep thoughts
257 > > >
258 > > > - Someone on the internet 1996
259 >
260 > Back to regular thoughts
261 "#},
262 indoc! { r#"## Lists
263
264 You can create an unordered list by adding a `-`, `*`, or `+` before the text.
265
266 - First list item
267 - Second list item
268 - Third list item
269
270 To create an ordered list, start each line with a number followed by a `.` symbol.
271
272 1. First list item
273 2. Second list item
274 3. Third list item
275 "#},
276 indoc! { r#"## Indented Lists
277
278 Lists can be indented
279
280 - First list item
281 - Second list item
282 - Third list item
283
284 "#},
285 indoc! { r#"## Task lists
286
287 To create a task list, start each list item with a hyphen and space followed by `[ ]`.
288
289 - [x] This is a completed task.
290 - [ ] This is an incomplete task.
291
292 >You can use any character inside the brackets to mark it as complete.
293
294 - [x] Oats
295 - [?] Flour
296 - [d] Apples
297 "#},
298 indoc! { r#"## Code blocks
299
300 To format a block of code, surround the code with triple backticks.
301
302 ```
303 cd ~/Desktop
304 ```
305
306 You can also create a code block by indenting the text using `Tab` or 4 blank spaces.
307
308 cd ~/Desktop
309 "#},
310 indoc! { r#"## Code blocks
311
312 You can add syntax highlighting to a code block, by adding a language code after the first set of backticks.
313
314 ```js
315 function fancyAlert(arg) {
316 if(arg) {
317 $.facebox({div:'#foo'})
318 }
319 }
320 ```
321 "#},
322 ];
323
324 let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
325
326 tests.iter().for_each(|text| {
327 _ = terminal.clear();
328 let mut state =
329 NoteEditorState::new(text, "Test", Path::new("test.md"), &Symbols::unicode());
330 terminal
331 .draw(|frame| {
332 NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
333 })
334 .unwrap();
335 assert_snapshot!(terminal.backend());
336 });
337 }
338
339 #[test]
340 fn test_rendered_editor_states() {
341 type TestCase = (&'static str, Box<dyn Fn(Rect) -> NoteEditorState<'static>>);
342
343 let content = indoc! { r#"## Deep Quotes
344
345 You can have deeper levels of quotes by adding a > symbols before the text inside the block quote.
346
347 > Regular thoughts
348 >
349 > > Deeper thoughts
350 > >
351 > > > Very deep thoughts
352 > > >
353 > > > - Someone on the internet 1996
354 >
355 > Back to regular thoughts
356 "#};
357
358 let tests: Vec<TestCase> = vec![
359 (
360 "empty_default_state",
361 Box::new(|_| NoteEditorState::default()),
362 ),
363 (
364 "read_mode_with_content",
365 Box::new(|_| {
366 NoteEditorState::new(content, "Test", Path::new("test.md"), &Symbols::unicode())
367 }),
368 ),
369 (
370 "edit_mode_with_content",
371 Box::new(|_| {
372 let mut state = NoteEditorState::new(
373 content,
374 "Test",
375 Path::new("test.md"),
376 &Symbols::unicode(),
377 );
378 state.set_view(View::Edit(EditMode::Source));
379 state
380 }),
381 ),
382 (
383 "edit_mode_with_content_and_simple_change",
384 Box::new(|area| {
385 let mut state = NoteEditorState::new(
386 content,
387 "Test",
388 Path::new("test.md"),
389 &Symbols::unicode(),
390 );
391 state.resize_viewport(area.as_size());
392 state.set_view(View::Edit(EditMode::Source));
393 state.insert_char('#');
394 state.exit_insert();
395 state.set_view(View::Read);
396 state
397 }),
398 ),
399 (
400 "edit_mode_with_arbitrary_cursor_move",
401 Box::new(|area| {
402 let mut state = NoteEditorState::new(
403 content,
404 "Test",
405 Path::new("test.md"),
406 &Symbols::unicode(),
407 );
408 state.resize_viewport(area.as_size());
409 state.set_view(View::Edit(EditMode::Source));
410 state.cursor_right(7);
411 state.insert_char(' ');
412 state.insert_char('B');
413 state.insert_char('a');
414 state.insert_char('s');
415 state.insert_char('a');
416 state.insert_char('l');
417 state.insert_char('t');
418 state.exit_insert();
419 state.set_view(View::Read);
420 state
421 }),
422 ),
423 (
424 "edit_mode_task_list_then_multiple_empty_lines",
425 Box::new(|_| {
426 let content = indoc! { r#"## Tasks
427 - [ ] one
428 - [ ] two
429
430
431
432 next paragraph
433 "#};
434 let mut state = NoteEditorState::new(
435 content,
436 "Test",
437 Path::new("test.md"),
438 &Symbols::unicode(),
439 );
440 state.set_view(View::Edit(EditMode::Source));
441 state
442 }),
443 ),
444 (
445 "edit_mode_typing_newline_in_active_block_stable_trailing",
446 Box::new(|area| {
447 let content = "para1\n\npara2\n";
448 let mut state = NoteEditorState::new(
449 content,
450 "Test",
451 Path::new("test.md"),
452 &Symbols::unicode(),
453 );
454 state.resize_viewport(area.as_size());
455 state.set_view(View::Edit(EditMode::Source));
456 state.cursor_right(5);
457 state.insert_char('\n');
458 state.insert_char('\n');
459 state
460 }),
461 ),
462 (
463 "edit_mode_no_empty_line_between_adjacent_blocks",
464 Box::new(|_| {
465 let content = indoc! { r#"## Heading
466 Paragraph immediately under heading.
467 - first item
468 - second item
469 "#};
470 let mut state = NoteEditorState::new(
471 content,
472 "Test",
473 Path::new("test.md"),
474 &Symbols::unicode(),
475 );
476 state.set_view(View::Edit(EditMode::Source));
477 state
478 }),
479 ),
480 (
481 "edit_mode_preserves_loose_list_empty_lines",
482 Box::new(|_| {
483 let content = indoc! { r#"## Lists with line breaks
484
485 1. First list item
486
487 2. Second list item
488 3. Third list item
489
490 4. Fourth list item
491 "#};
492 let mut state = NoteEditorState::new(
493 content,
494 "Test",
495 Path::new("test.md"),
496 &Symbols::unicode(),
497 );
498 state.set_view(View::Edit(EditMode::Source));
499 state
500 }),
501 ),
502 (
503 "edit_mode_with_content_with_complete_word_input_change",
504 Box::new(|area| {
505 let mut state = NoteEditorState::new(
506 content,
507 "Test",
508 Path::new("test.md"),
509 &Symbols::unicode(),
510 );
511 state.resize_viewport(area.as_size());
512 state.cursor_down(1);
513 state.set_view(View::Edit(EditMode::Source));
514 state.insert_char('\n');
515 state.insert_char('B');
516 state.insert_char('a');
517 state.insert_char('s');
518 state.insert_char('a');
519 state.insert_char('l');
520 state.insert_char('t');
521 state.insert_char('\n');
522 state.insert_char('\n');
523 state.exit_insert();
524 state.set_view(View::Read);
525 state
526 }),
527 ),
528 (
529 "edit_mode_table_cursor_header",
532 Box::new(|area| {
533 let content = indoc! { r#"## Tables
534
535 | Name | Role |
536 | :---- | :--------: |
537 | Alice | Maintainer |
538 | Bob | Reviewer |
539 "#};
540 let mut state = NoteEditorState::new(
541 content,
542 "Test",
543 Path::new("test.md"),
544 &Symbols::unicode(),
545 );
546 state.resize_viewport(area.as_size());
547 state.set_view(View::Edit(EditMode::Source));
548 state.cursor_down(1);
549 state
550 }),
551 ),
552 (
553 "edit_mode_table_broken_is_raw",
557 Box::new(|area| {
558 let content = indoc! { r#"## Tables
559
560 | Name | Role |
561 | :xx-- | :--------: |
562 | Alice | Maintainer |
563 | Bob | Reviewer |
564 "#};
565 let mut state = NoteEditorState::new(
566 content,
567 "Test",
568 Path::new("test.md"),
569 &Symbols::unicode(),
570 );
571 state.resize_viewport(area.as_size());
572 state.set_view(View::Edit(EditMode::Source));
573 state.cursor_down(1);
574 state
575 }),
576 ),
577 (
578 "edit_mode_table_break_column_count",
583 Box::new(|area| {
584 let content = indoc! { r#"## Tables
585
586 | Name | Role |
587 | ---- | ---- |
588 | A | B |
589 "#};
590 let mut state = NoteEditorState::new(
591 content,
592 "Test",
593 Path::new("test.md"),
594 &Symbols::unicode(),
595 );
596 state.resize_viewport(area.as_size());
597 state.set_view(View::Edit(EditMode::Source));
598 state.cursor_down(1);
600 state.cursor_down(1);
601 state.cursor_right(40);
602 for _ in 0..7 {
603 state.delete_char();
604 }
605 state.cursor_up(1);
607 state
608 }),
609 ),
610 (
611 "edit_mode_table_cursor_delimiter",
614 Box::new(|area| {
615 let content = indoc! { r#"## Tables
616
617 | Name | Role |
618 | :---- | :--------: |
619 | Alice | Maintainer |
620 | Bob | Reviewer |
621 "#};
622 let mut state = NoteEditorState::new(
623 content,
624 "Test",
625 Path::new("test.md"),
626 &Symbols::unicode(),
627 );
628 state.resize_viewport(area.as_size());
629 state.set_view(View::Edit(EditMode::Source));
630 state.cursor_down(1);
631 state.cursor_down(1);
632 state
633 }),
634 ),
635 (
636 "edit_mode_table_cursor_body",
638 Box::new(|area| {
639 let content = indoc! { r#"## Tables
640
641 | Name | Role |
642 | :---- | :--------: |
643 | Alice | Maintainer |
644 | Bob | Reviewer |
645 "#};
646 let mut state = NoteEditorState::new(
647 content,
648 "Test",
649 Path::new("test.md"),
650 &Symbols::unicode(),
651 );
652 state.resize_viewport(area.as_size());
653 state.set_view(View::Edit(EditMode::Source));
654 state.cursor_down(1);
655 state.cursor_down(1);
656 state.cursor_down(1);
657 state
658 }),
659 ),
660 (
661 "edit_mode_list_line_by_line_raw",
664 Box::new(|area| {
665 let content = indoc! { r#"## Shopping
666
667 - apples
668 - bananas
669 - cherries
670 "#};
671 let mut state = NoteEditorState::new(
672 content,
673 "Test",
674 Path::new("test.md"),
675 &Symbols::unicode(),
676 );
677 state.resize_viewport(area.as_size());
678 state.set_view(View::Edit(EditMode::Source));
679 state.cursor_down(1);
682 state.cursor_down(1);
683 state
684 }),
685 ),
686 ];
687
688 let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
689
690 tests.into_iter().for_each(|(name, state_fn)| {
691 _ = terminal.clear();
692 terminal
693 .draw(|frame| {
694 let mut state = state_fn(frame.area());
695 NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
696 })
697 .unwrap();
698 assert_snapshot!(name, terminal.backend());
699 });
700 }
701
702 #[test]
703 fn test_basic_formatting() {
704 let tests = [
705 (
706 "paragraphs",
707 indoc! { r#"## Paragraphs
708 To create paragraphs in Markdown, use a **blank line** to separate blocks of text. Each block of text separated by a blank line is treated as a distinct paragraph.
709
710 This is a paragraph.
711
712 This is another paragraph.
713
714 A blank line between lines of text creates separate paragraphs. This is the default behavior in Markdown.
715 "#},
716 ),
717 (
718 "headings",
719 indoc! { r#"## Headings
720 To create a heading, add up to six `#` symbols before your heading text. The number of `#` symbols determines the size of the heading.
721
722 # This is a heading 1
723 ## This is a heading 2
724 ### This is a heading 3
725 #### This is a heading 4
726 ##### This is a heading 5
727 ###### This is a heading 6
728 "#},
729 ),
730 (
731 "lists",
732 indoc! { r#"## Lists
733 You can create an unordered list by adding a `-`, `*`, or `+` before the text.
734
735 - First list item
736 - Second list item
737 - Third list item
738
739 To create an ordered list, start each line with a number followed by a `.` or `)` symbol.
740
741 1. First list item
742 2. Second list item
743 3. Third list item
744
745 1) First list item
746 2) Second list item
747 3) Third list item
748 "#},
749 ),
750 (
751 "lists_line_breaks",
752 indoc! { r#"## Lists with line breaks
753 You can use line breaks within an ordered list without altering the numbering.
754
755 1. First list item
756
757 2. Second list item
758 3. Third list item
759
760 4. Fourth list item
761 5. Fifth list item
762 6. Sixth list item
763 "#},
764 ),
765 (
766 "task_lists",
767 indoc! { r#"## Task lists
768 To create a task list, start each list item with a hyphen and space followed by `[ ]`.
769
770 - [x] This is a completed task.
771 - [ ] This is an incomplete task.
772
773 You can toggle a task in Reading view by selecting the checkbox.
774
775 > [!tip]
776 > You can use any character inside the brackets to mark it as complete.
777 >
778 > - [x] Milk
779 > - [?] Eggs
780 > - [-] Eggs
781 "#},
782 ),
783 (
784 "nesting_lists",
785 indoc! { r#"## Nesting lists
786 You can nest any type of list—ordered, unordered, or task lists—under any other type of list.
787
788 To create a nested list, indent one or more list items. You can mix list types within a nested structure:
789
790 1. First list item
791 1. Ordered nested list item
792 2. Second list item
793 - Unordered nested list item
794 "#},
795 ),
796 (
797 "nesting_task_lists",
798 indoc! { r#"## Nesting task lists
799 Similarly, you can create a nested task list by indenting one or more list items:
800
801 - [ ] Task item 1
802 - [ ] Subtask 1
803 - [ ] Task item 2
804 - [ ] Subtask 1
805 "#},
806 ),
807 (
825 "tables",
826 indoc! { r#"## Tables
827 Columns size to their content and wrap long text so the table fits.
828
829 | Name | Role | Notes |
830 | :---- | :--------: | ---------------------------------------------------------------: |
831 | Alice | Maintainer | Writes most of the core code and reviews incoming pull requests. |
832 | Bob | Reviewer | Short note. |
833 "#},
834 ),
835 (
836 "code_blocks",
837 indoc! { r#"## Code blocks
838 To format code as a block, enclose it with three backticks or three tildes.
839
840 ```md
841 cd ~/Desktop
842 ```
843
844 You can also create a code block by indenting the text using `Tab` or 4 blank spaces.
845
846 cd ~/Desktop
847
848 "#},
849 ),
850 (
851 "code_syntax_highlighting_in_blocks",
852 indoc! { r#"## Code syntax highlighting in blocks
853 You can add syntax highlighting to a code block, by adding a language code after the first set of backticks.
854
855 ```js
856 function fancyAlert(arg) {
857 if(arg) {
858 $.facebox({div:'#foo'})
859 }
860 }
861 ```
862 "#},
863 ),
864 ];
865
866 let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
867
868 tests.into_iter().for_each(|(name, content)| {
869 let mut state =
870 NoteEditorState::new(content, name, Path::new("test.md"), &Symbols::unicode());
871 _ = terminal.clear();
872 terminal
873 .draw(|frame| {
874 NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
875 })
876 .unwrap();
877 assert_snapshot!(name, terminal.backend());
878 });
879 }
880
881 #[test]
882 fn test_selected_list_marker_is_highlighted() {
883 use ratatui::{layout::Size, style::Modifier};
884
885 let mut state = NoteEditorState::new(
886 "- alpha\n- beta\n",
887 "test",
888 Path::new("test.md"),
889 &Symbols::unicode(),
890 );
891 state.set_vim_mode(true);
892 state.set_editor_enabled(true);
893 state.resize_viewport(Size::new(40, 10));
894 state.set_view(View::Edit(EditMode::Source));
895
896 state.toggle_selection(SelectionMode::Line);
900 state.cursor_down(1);
901
902 let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap();
903 terminal
904 .draw(|frame| {
905 NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
906 })
907 .unwrap();
908
909 let highlighted = terminal
910 .backend()
911 .buffer()
912 .content
913 .iter()
914 .any(|cell| cell.symbol() == "●" && cell.modifier.contains(Modifier::REVERSED));
915
916 assert!(
917 highlighted,
918 "the prettified list marker on a selected line should be highlighted"
919 );
920 }
921}