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 .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 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 .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 (
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}