Skip to main content

basalt_tui/note_editor/
editor.rs

1use std::marker::PhantomData;
2
3use ratatui::{
4    buffer::Buffer,
5    layout::{Offset, Rect},
6    style::{Color, Stylize},
7    text::Line,
8    widgets::{
9        Block, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
10        Widget,
11    },
12};
13
14use crate::note_editor::{
15    cursor::CursorWidget,
16    state::{NoteEditorState, View},
17};
18
19#[derive(Default)]
20pub struct NoteEditor<'a>(pub PhantomData<&'a ()>);
21
22impl<'a> StatefulWidget for NoteEditor<'a> {
23    type State = NoteEditorState<'a>;
24
25    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
26        let (mode_label, mode_color) = match state.view {
27            View::Edit(..) if state.vim_mode() && state.insert_mode() => ("INSERT", Color::Green),
28            View::Edit(..) if state.vim_mode() => ("NORMAL", Color::Yellow),
29            View::Edit(..) => ("EDIT", Color::Green),
30            View::Read => ("READ", Color::Red),
31        };
32
33        let block = Block::bordered()
34            .border_type(if state.active() {
35                state.symbols.border_active.into()
36            } else {
37                state.symbols.border_inactive.into()
38            })
39            // NOTE: Uncomment for debugging
40            // .title_top(format!(
41            //     "{},{},{}",
42            //     state.cursor.virtual_line(),
43            //     state.cursor.virtual_column(),
44            //     state.cursor.source_offset()
45            // ))
46            .title_bottom(
47                [
48                    format!(" {mode_label}").fg(mode_color).bold().italic(),
49                    if state.modified() {
50                        "* ".bold().italic()
51                    } else {
52                        " ".into()
53                    },
54                ]
55                .to_vec(),
56            )
57            .padding(Padding::horizontal(1));
58
59        let inner_area = block.inner(area);
60
61        // NOTE: We only reliably know the size of the area for the editor once we arrive at this point.
62        // Calling the resize_width will cause the visual blocks to be populated in the state.
63        // If width or height is not changed between frames, the resize_width is a noop.
64        state.resize_viewport(inner_area.as_size());
65
66        state.update_layout();
67
68        let mut lines = state.virtual_document.meta().to_vec();
69        lines.extend(state.virtual_document.lines().to_vec());
70
71        let visible_lines = lines
72            .iter()
73            .skip(state.viewport().top() as usize)
74            .take(state.viewport().bottom() as usize)
75            // Cheaper to clone the subset of the lines
76            .cloned()
77            .map(|visual_line| visual_line.into())
78            .collect::<Vec<Line>>();
79
80        let rendered_lines_count = state.virtual_document.lines().len();
81        let meta_lines_count = state.virtual_document.meta().len();
82
83        Paragraph::new(visible_lines).block(block).render(area, buf);
84
85        if !state.content.is_empty() || state.is_editing() {
86            CursorWidget::default()
87                .with_offset(Offset {
88                    x: inner_area.x as i32,
89                    y: inner_area.y as i32 + meta_lines_count as i32,
90                })
91                .render(state.viewport().area(), buf, &mut state.cursor);
92        }
93
94        if !area.is_empty() && lines.len() as u16 > inner_area.bottom() {
95            let mut scroll_state =
96                ScrollbarState::new(rendered_lines_count).position(state.cursor.virtual_row());
97
98            Scrollbar::new(ScrollbarOrientation::VerticalRight).render(
99                area,
100                buf,
101                &mut scroll_state,
102            );
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use std::path::Path;
110
111    use crate::{config::Symbols, note_editor::state::EditMode};
112
113    use super::*;
114    use indoc::indoc;
115    use insta::assert_snapshot;
116    use ratatui::{backend::TestBackend, Terminal};
117
118    #[test]
119    fn test_rendered_markdown_view() {
120        let tests = [
121            indoc! { r#"## Headings
122
123            # This is a heading 1
124
125            ## This is a heading 2
126
127            ### This is a heading 3
128
129            #### This is a heading 4
130
131            ##### This is a heading 5
132
133            ###### This is a heading 6
134            "#},
135            indoc! { r#"## Quotes
136
137            You can quote text by adding a > symbols before the text.
138
139            > 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.
140            >
141            > - Doug Engelbart, 1961
142            "#},
143            indoc! { r#"## Callout Blocks
144
145            > [!tip]
146            >
147            >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.
148            "#},
149            indoc! { r#"## Deep Quotes
150
151            You can have deeper levels of quotes by adding a > symbols before the text inside the block quote.
152
153            > Regular thoughts
154            >
155            > > Deeper thoughts
156            > >
157            > > > Very deep thoughts
158            > > >
159            > > > - Someone on the internet 1996
160            >
161            > Back to regular thoughts
162            "#},
163            indoc! { r#"## Lists
164
165            You can create an unordered list by adding a `-`, `*`, or `+` before the text.
166
167            - First list item
168            - Second list item
169            - Third list item
170
171            To create an ordered list, start each line with a number followed by a `.` symbol.
172
173            1. First list item
174            2. Second list item
175            3. Third list item
176            "#},
177            indoc! { r#"## Indented Lists
178
179            Lists can be indented
180
181            - First list item
182              - Second list item
183                - Third list item
184
185            "#},
186            indoc! { r#"## Task lists
187
188            To create a task list, start each list item with a hyphen and space followed by `[ ]`.
189
190            - [x] This is a completed task.
191            - [ ] This is an incomplete task.
192
193            >You can use any character inside the brackets to mark it as complete.
194
195            - [x] Oats
196            - [?] Flour
197            - [d] Apples
198            "#},
199            indoc! { r#"## Code blocks
200
201            To format a block of code, surround the code with triple backticks.
202
203            ```
204            cd ~/Desktop
205            ```
206
207            You can also create a code block by indenting the text using `Tab` or 4 blank spaces.
208
209                cd ~/Desktop
210            "#},
211            indoc! { r#"## Code blocks
212
213            You can add syntax highlighting to a code block, by adding a language code after the first set of backticks.
214
215            ```js
216            function fancyAlert(arg) {
217              if(arg) {
218                $.facebox({div:'#foo'})
219              }
220            }
221            ```
222            "#},
223        ];
224
225        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
226
227        tests.iter().for_each(|text| {
228            _ = terminal.clear();
229            let mut state =
230                NoteEditorState::new(text, "Test", Path::new("test.md"), &Symbols::unicode());
231            terminal
232                .draw(|frame| {
233                    NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
234                })
235                .unwrap();
236            assert_snapshot!(terminal.backend());
237        });
238    }
239
240    #[test]
241    fn test_rendered_editor_states() {
242        type TestCase = (&'static str, Box<dyn Fn(Rect) -> NoteEditorState<'static>>);
243
244        let content = indoc! { r#"## Deep Quotes
245
246            You can have deeper levels of quotes by adding a > symbols before the text inside the block quote.
247
248            > Regular thoughts
249            >
250            > > Deeper thoughts
251            > >
252            > > > Very deep thoughts
253            > > >
254            > > > - Someone on the internet 1996
255            >
256            > Back to regular thoughts
257            "#};
258
259        let tests: Vec<TestCase> = vec![
260            (
261                "empty_default_state",
262                Box::new(|_| NoteEditorState::default()),
263            ),
264            (
265                "read_mode_with_content",
266                Box::new(|_| {
267                    NoteEditorState::new(content, "Test", Path::new("test.md"), &Symbols::unicode())
268                }),
269            ),
270            (
271                "edit_mode_with_content",
272                Box::new(|_| {
273                    let mut state = NoteEditorState::new(
274                        content,
275                        "Test",
276                        Path::new("test.md"),
277                        &Symbols::unicode(),
278                    );
279                    state.set_view(View::Edit(EditMode::Source));
280                    state
281                }),
282            ),
283            (
284                "edit_mode_with_content_and_simple_change",
285                Box::new(|area| {
286                    let mut state = NoteEditorState::new(
287                        content,
288                        "Test",
289                        Path::new("test.md"),
290                        &Symbols::unicode(),
291                    );
292                    state.resize_viewport(area.as_size());
293                    state.set_view(View::Edit(EditMode::Source));
294                    state.insert_char('#');
295                    state.exit_insert();
296                    state.set_view(View::Read);
297                    state
298                }),
299            ),
300            (
301                "edit_mode_with_arbitrary_cursor_move",
302                Box::new(|area| {
303                    let mut state = NoteEditorState::new(
304                        content,
305                        "Test",
306                        Path::new("test.md"),
307                        &Symbols::unicode(),
308                    );
309                    state.resize_viewport(area.as_size());
310                    state.set_view(View::Edit(EditMode::Source));
311                    state.cursor_right(7);
312                    state.insert_char(' ');
313                    state.insert_char('B');
314                    state.insert_char('a');
315                    state.insert_char('s');
316                    state.insert_char('a');
317                    state.insert_char('l');
318                    state.insert_char('t');
319                    state.exit_insert();
320                    state.set_view(View::Read);
321                    state
322                }),
323            ),
324            (
325                "edit_mode_with_content_with_complete_word_input_change",
326                Box::new(|area| {
327                    let mut state = NoteEditorState::new(
328                        content,
329                        "Test",
330                        Path::new("test.md"),
331                        &Symbols::unicode(),
332                    );
333                    state.resize_viewport(area.as_size());
334                    state.cursor_down(1);
335                    state.set_view(View::Edit(EditMode::Source));
336                    state.insert_char('\n');
337                    state.insert_char('B');
338                    state.insert_char('a');
339                    state.insert_char('s');
340                    state.insert_char('a');
341                    state.insert_char('l');
342                    state.insert_char('t');
343                    state.insert_char('\n');
344                    state.insert_char('\n');
345                    state.exit_insert();
346                    state.set_view(View::Read);
347                    state
348                }),
349            ),
350        ];
351
352        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
353
354        tests.into_iter().for_each(|(name, state_fn)| {
355            _ = terminal.clear();
356            terminal
357                .draw(|frame| {
358                    let mut state = state_fn(frame.area());
359                    NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
360                })
361                .unwrap();
362            assert_snapshot!(name, terminal.backend());
363        });
364    }
365
366    #[test]
367    fn test_basic_formatting() {
368        let tests = [
369            (
370                "paragraphs",
371                indoc! { r#"## Paragraphs
372                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.
373
374                This is a paragraph.
375
376                This is another paragraph.
377
378                A blank line between lines of text creates separate paragraphs. This is the default behavior in Markdown.
379                "#},
380            ),
381            (
382                "headings",
383                indoc! { r#"## Headings
384                To create a heading, add up to six `#` symbols before your heading text. The number of `#` symbols determines the size of the heading.
385
386                # This is a heading 1
387                ## This is a heading 2
388                ### This is a heading 3
389                #### This is a heading 4
390                ##### This is a heading 5
391                ###### This is a heading 6
392                "#},
393            ),
394            (
395                "lists",
396                indoc! { r#"## Lists
397                You can create an unordered list by adding a `-`, `*`, or `+` before the text.
398
399                - First list item
400                - Second list item
401                - Third list item
402
403                To create an ordered list, start each line with a number followed by a `.` or `)` symbol.
404
405                1. First list item
406                2. Second list item
407                3. Third list item
408
409                1) First list item
410                2) Second list item
411                3) Third list item
412                "#},
413            ),
414            (
415                "lists_line_breaks",
416                indoc! { r#"## Lists with line breaks
417                You can use line breaks within an ordered list without altering the numbering.
418
419                1. First list item
420
421                2. Second list item
422                3. Third list item
423
424                4. Fourth list item
425                5. Fifth list item
426                6. Sixth list item
427                "#},
428            ),
429            (
430                "task_lists",
431                indoc! { r#"## Task lists
432                To create a task list, start each list item with a hyphen and space followed by `[ ]`.
433
434                - [x] This is a completed task.
435                - [ ] This is an incomplete task.
436
437                You can toggle a task in Reading view by selecting the checkbox.
438
439                > [!tip]
440                > You can use any character inside the brackets to mark it as complete.
441                >
442                > - [x] Milk
443                > - [?] Eggs
444                > - [-] Eggs
445                "#},
446            ),
447            (
448                "nesting_lists",
449                indoc! { r#"## Nesting lists
450                You can nest any type of list—ordered, unordered, or task lists—under any other type of list.
451
452                To create a nested list, indent one or more list items. You can mix list types within a nested structure:
453
454                1. First list item
455                   1. Ordered nested list item
456                2. Second list item
457                   - Unordered nested list item
458                "#},
459            ),
460            (
461                "nesting_task_lists",
462                indoc! { r#"## Nesting task lists
463                Similarly, you can create a nested task list by indenting one or more list items:
464
465                - [ ] Task item 1
466                  - [ ] Subtask 1
467                - [ ] Task item 2
468                  - [ ] Subtask 1
469                "#},
470            ),
471            // TODO: Implement horizontal rule
472            // (
473            //     "horizontal_rule",
474            //     indoc! { r#"## Horizontal rule
475            //     You can use three or more stars `***`, hyphens `---`, or underscore `___` on its own line to add a horizontal bar. You can also separate symbols using spaces.
476            //
477            //     ***
478            //     ****
479            //     * * *
480            //     ---
481            //     ----
482            //     - - -
483            //     ___
484            //     ____
485            //     _ _ _
486            //     "#},
487            // ),
488            (
489                "code_blocks",
490                indoc! { r#"## Code blocks
491                To format code as a block, enclose it with three backticks or three tildes.
492
493                ```md
494                cd ~/Desktop
495                ```
496
497                You can also create a code block by indenting the text using `Tab` or 4 blank spaces.
498
499                    cd ~/Desktop
500
501                "#},
502            ),
503            (
504                "code_syntax_highlighting_in_blocks",
505                indoc! { r#"## Code syntax highlighting in blocks
506                You can add syntax highlighting to a code block, by adding a language code after the first set of backticks.
507
508                ```js
509                function fancyAlert(arg) {
510                  if(arg) {
511                    $.facebox({div:'#foo'})
512                  }
513                }
514                ```
515                "#},
516            ),
517        ];
518
519        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
520
521        tests.into_iter().for_each(|(name, content)| {
522            let mut state =
523                NoteEditorState::new(content, name, Path::new("test.md"), &Symbols::unicode());
524            _ = terminal.clear();
525            terminal
526                .draw(|frame| {
527                    NoteEditor::default().render(frame.area(), frame.buffer_mut(), &mut state)
528                })
529                .unwrap();
530            assert_snapshot!(name, terminal.backend());
531        });
532    }
533}