Skip to main content

basalt_tui/note_editor/
editor.rs

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                    // A synthetic span (rendered list marker, prefix, quote glyph)
65                    // stands in for source it does not carry. Highlight it when the
66                    // content it precedes is selected, so a selected line's marker
67                    // is not left blank.
68                    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        // The editor mode is shown in the status bar and the modified marker in
102        // the tab, so the border only carries the pending-keys hint (which-key),
103        // and only while keys are pending, keeping a clean note's border unbroken.
104        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        // NOTE: We only reliably know the size of the area for the editor once we arrive at this point.
129        // Calling the resize_width will cause the visual blocks to be populated in the state.
130        // If width or height is not changed between frames, the resize_width is a noop.
131        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            // Cheaper to clone the subset of the lines
143            .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                // A table edits as a box; only the cursor's row reveals raw. Here
530                // the cursor is on the header row.
531                "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                // A broken table (invalid delimiter row) is edited fully raw so the
554                // broken markdown stays visible and fixable — no box hides it, even
555                // with the cursor away from the broken line.
556                "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                // Breaking a live table by deleting a delimiter column (so the
579                // delimiter no longer matches the header) drops it out of table
580                // syntax. Even though the cursor moves away from the broken line, the
581                // whole block falls back to raw so it stays visible and fixable.
582                "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                    // Onto the delimiter row, then delete its second column.
599                    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                    // Move the cursor back up to the header, away from the break.
606                    state.cursor_up(1);
607                    state
608                }),
609            ),
610            (
611                // The delimiter row is reachable too: landing on it reveals it raw
612                // so its alignment markers can be edited.
613                "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                // Stepping down reveals a body row raw while the rest stays boxed.
637                "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                // Only the list item under the cursor should render raw; the
662                // surrounding items stay rendered. Ref: issue #486.
663                "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                    // Enter the list (lands on the first item), then step down to
680                    // the "bananas" item.
681                    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            // TODO: Implement horizontal rule
808            // (
809            //     "horizontal_rule",
810            //     indoc! { r#"## Horizontal rule
811            //     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.
812            //
813            //     ***
814            //     ****
815            //     * * *
816            //     ---
817            //     ----
818            //     - - -
819            //     ___
820            //     ____
821            //     _ _ _
822            //     "#},
823            // ),
824            (
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        // Linewise-select from the first item down onto the second. The cursor
897        // lands on line two (rendered raw), so line one keeps its prettified
898        // "●" marker while sitting inside the selection.
899        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}