1use std::{
2 borrow::Cow,
3 fmt,
4 fs::File,
5 io::{self, Write},
6 ops::Range,
7 path::{Path, PathBuf},
8 time::{Duration, Instant},
9};
10
11use ratatui::{layout::Size, style::Color};
12
13use crate::{
14 config::{Symbols, Theme},
15 note_editor::{
16 ast::{self},
17 cursor::{self, Cursor},
18 motion::{Direction, TextObjectKind},
19 parser,
20 rich_text::RichText,
21 text_buffer::TextBuffer,
22 viewport::Viewport,
23 virtual_document::VirtualDocument,
24 },
25};
26
27#[derive(Clone, Copy, Debug, Default, PartialEq)]
28pub enum EditMode {
29 #[default]
30 Source,
32 }
36
37#[derive(Clone, Copy, Debug, Default, PartialEq)]
38pub enum View {
39 #[default]
40 Read,
41 Edit(EditMode),
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum SelectionMode {
46 Char,
47 Line,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct Selection {
54 pub anchor: usize,
55 pub mode: SelectionMode,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct FindKind {
61 pub direction: Direction,
62 pub till: bool,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67struct FindMotion {
68 target: char,
69 kind: FindKind,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75enum Pending {
76 Find(FindKind),
77 TextObject(TextObjectKind),
78 Replace,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Operator {
84 Delete,
85 Change,
86 Yank,
87}
88
89#[derive(Clone, Debug, Default)]
91pub struct Register {
92 pub text: String,
93 pub linewise: bool,
94}
95
96#[derive(Clone, Debug)]
98struct Snapshot {
99 content: String,
100 offset: usize,
101}
102
103const YANK_FLASH_DURATION: Duration = Duration::from_millis(150);
104
105#[derive(Clone, Debug)]
106struct YankFlash {
107 range: Range<usize>,
108 started: Instant,
109}
110
111impl fmt::Display for View {
112 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
113 match self {
114 View::Read => write!(f, "READ"),
115 View::Edit(..) => write!(f, "EDIT"),
116 }
117 }
118}
119
120#[derive(Clone, Copy, Debug, Default, PartialEq)]
122pub enum Mode {
123 Insert,
124 Normal,
125 Visual,
126 VisualLine,
127 Edit,
128 #[default]
129 Read,
130}
131
132impl Mode {
133 pub fn label(self) -> &'static str {
134 match self {
135 Mode::Insert => "INSERT",
136 Mode::Normal => "NORMAL",
137 Mode::Visual => "VISUAL",
138 Mode::VisualLine => "V-LINE",
139 Mode::Edit => "EDIT",
140 Mode::Read => "READ",
141 }
142 }
143
144 pub fn color(self, theme: &Theme) -> Color {
146 match self {
147 Mode::Insert | Mode::Edit => theme.mode_insert,
148 Mode::Normal => theme.mode_normal,
149 Mode::Visual | Mode::VisualLine => theme.accent,
150 Mode::Read => theme.mode_read,
151 }
152 }
153}
154
155#[derive(Clone, Debug, Default)]
156pub struct NoteEditorState<'a> {
157 pub content: String,
159 pub view: View,
160 pub cursor: Cursor,
161 pub ast_nodes: Vec<ast::Node>,
162 pub virtual_document: VirtualDocument<'a>,
163 pub symbols: Symbols,
164 theme: Theme,
165 filepath: PathBuf,
166 filename: String,
167 active: bool,
168 insert_mode: bool,
169 vim_mode: bool,
170 editor_enabled: bool,
171 modified: bool,
172 viewport: Viewport,
173 selection: Option<Selection>,
174 yank_flash: Option<YankFlash>,
175 pending_count: Option<usize>,
176 pending: Option<Pending>,
177 last_find: Option<FindMotion>,
178 pending_operator: Option<Operator>,
179 register: Register,
180 undo_stack: Vec<Snapshot>,
181 redo_stack: Vec<Snapshot>,
182 text_buffer: Option<TextBuffer>,
183 editing_block: Option<usize>,
187}
188
189impl<'a> NoteEditorState<'a> {
190 pub fn new(content: &str, filename: &str, filepath: &Path, symbols: &Symbols) -> Self {
191 let ast_nodes = parser::from_str(content);
192 let content = content.to_string();
193 Self {
194 text_buffer: None,
195 content,
196 view: View::Read,
197 cursor: Cursor::default(),
198 viewport: Viewport::default(),
199 symbols: symbols.clone(),
200 theme: Theme::default(),
201 virtual_document: VirtualDocument::new(symbols),
202 filename: filename.to_string(),
203 filepath: filepath.to_path_buf(),
204 ast_nodes,
205 active: false,
206 insert_mode: false,
207 vim_mode: false,
208 editor_enabled: false,
209 modified: false,
210 selection: None,
211 yank_flash: None,
212 pending_count: None,
213 pending: None,
214 last_find: None,
215 pending_operator: None,
216 register: Register::default(),
217 undo_stack: Vec::new(),
218 redo_stack: Vec::new(),
219 editing_block: None,
220 }
221 }
222
223 pub fn viewport(&self) -> &Viewport {
224 &self.viewport
225 }
226
227 pub fn is_editing(&self) -> bool {
228 matches!(self.view, View::Edit(..))
229 }
230
231 pub fn insert_mode(&self) -> bool {
232 self.insert_mode
233 }
234
235 pub fn set_insert_mode(&mut self, mode: bool) {
236 self.insert_mode = mode;
237 }
238
239 pub fn vim_mode(&self) -> bool {
240 self.vim_mode
241 }
242
243 pub fn mode(&self) -> Mode {
245 match self.view {
246 View::Edit(..) if self.vim_mode && self.insert_mode => Mode::Insert,
247 View::Edit(..) if self.vim_mode && self.is_selecting() => {
248 match self.selection().map(|selection| selection.mode) {
249 Some(SelectionMode::Line) => Mode::VisualLine,
250 _ => Mode::Visual,
251 }
252 }
253 View::Edit(..) if self.vim_mode => Mode::Normal,
254 View::Edit(..) => Mode::Edit,
255 View::Read => Mode::Read,
256 }
257 }
258
259 pub fn set_vim_mode(&mut self, mode: bool) {
260 self.vim_mode = mode;
261 }
262
263 pub fn editor_enabled(&self) -> bool {
264 self.editor_enabled
265 }
266
267 pub fn set_editor_enabled(&mut self, enabled: bool) {
268 self.editor_enabled = enabled;
269 }
270
271 pub fn text_buffer(&self) -> Option<&TextBuffer> {
272 self.text_buffer.as_ref()
273 }
274
275 pub fn enter_insert(&mut self, block_idx: usize) {
276 self.commit_text_buffer();
278
279 self.editing_block = Some(block_idx);
280 if let Some(node) = self.ast_nodes.get(block_idx) {
281 let source_range = node.source_range();
282 if let Some(content) = self.content.get(source_range.clone()) {
283 self.text_buffer = Some(TextBuffer::new(content, source_range.clone()));
284 }
285 } else if self.content.is_empty() {
286 let empty_node = ast::Node::Paragraph {
289 text: RichText::empty(),
290 source_range: 0..0,
291 };
292 self.text_buffer = Some(TextBuffer::new("", empty_node.source_range().clone()));
293 self.ast_nodes.push(empty_node);
294 }
295 }
296
297 pub fn exit_insert(&mut self) {
298 if matches!(self.view, View::Read) {
299 return;
300 }
301
302 self.commit_text_buffer();
303 self.text_buffer = None;
304 self.editing_block = None;
305 }
306
307 pub fn commit_text_buffer(&mut self) -> Option<()> {
310 let buffer = self.text_buffer()?;
311 if buffer.modified {
312 let new_content = buffer.write(&self.content);
313 let changed = self.content != new_content;
314 self.content = new_content;
315 self.ast_nodes = parser::from_str(&self.content);
316 self.modified = self.modified || changed;
317 Some(())
318 } else {
319 None
320 }
321 }
322
323 pub fn set_filename(&mut self, name: &str) {
324 self.filename = name.to_string();
325 }
326
327 pub fn set_filepath(&mut self, path: &Path) {
328 self.filepath = path.to_path_buf();
329 }
330
331 pub fn insert_char(&mut self, c: char) {
332 if let Some(buffer) = &mut self.text_buffer {
333 let source_pos = self.cursor.source_offset();
334 buffer.insert_char(c, source_pos);
335
336 let char_byte_len = c.len_utf8();
338 self.shift_source_ranges(source_pos, char_byte_len as isize);
339
340 self.cursor.update(
343 cursor::Message::Jump(source_pos + char_byte_len),
344 self.virtual_document.lines(),
345 &self.text_buffer,
346 );
347
348 self.update_layout();
349
350 self.ensure_cursor_visible();
351 }
352 }
353
354 pub fn delete_char(&mut self) -> Option<()> {
355 let buffer = self.text_buffer()?;
358
359 let at_buffer_start = buffer.source_range.start == self.cursor.source_offset();
362
363 let previous_block_idx = self.previous_block_idx();
365 let current_block_idx = self.current_block_idx();
366
367 let should_merge = at_buffer_start && previous_block_idx < current_block_idx;
368
369 if should_merge {
370 let prev_start = self.ast_nodes.get(previous_block_idx)?.source_range().start;
371 let buffer_start = self.text_buffer.as_ref()?.source_range.start;
372
373 let prefix = self.content.get(prev_start..buffer_start)?;
374 self.text_buffer
376 .as_mut()?
377 .insert_at_start(prev_start, prefix);
378 self.editing_block = Some(previous_block_idx);
380 self.ast_nodes.remove(current_block_idx);
382 }
383
384 let buffer = self.text_buffer.as_mut()?;
386
387 let source_pos = self.cursor.source_offset();
388 let deleted_char_byte_len = buffer.delete_char(source_pos)?;
389
390 let deleted_at = source_pos.saturating_sub(deleted_char_byte_len);
393 self.shift_source_ranges(deleted_at, -(deleted_char_byte_len as isize));
394
395 self.cursor.update(
398 cursor::Message::Jump(deleted_at),
399 self.virtual_document.lines(),
400 &self.text_buffer,
401 );
402
403 self.update_layout();
404
405 self.ensure_cursor_visible();
406
407 Some(())
408 }
409
410 pub fn active(&self) -> bool {
411 self.active
412 }
413
414 pub fn previous_block_idx(&self) -> usize {
415 let prev_line = self.cursor.virtual_row().saturating_sub(1);
416 self.virtual_document.line_to_block_idx(prev_line)
417 }
418
419 pub fn current_block_idx(&self) -> usize {
420 let current_line = self.cursor.virtual_row();
421 self.virtual_document.line_to_block_idx(current_line)
422 }
423
424 pub fn set_view(&mut self, view: View) {
425 let block_idx = self.current_block_idx();
426
427 self.view = view;
428
429 use cursor::Message::*;
430
431 match self.view {
432 View::Read => {
433 self.exit_insert();
434 self.update_layout();
435 self.cursor.update(
436 SwitchMode(cursor::CursorMode::Read),
437 self.virtual_document.lines(),
438 &None,
439 );
440 self.ensure_cursor_visible();
443 }
444 View::Edit(..) => {
445 self.enter_insert(block_idx);
446 self.update_layout();
447 self.cursor.update(
448 SwitchMode(cursor::CursorMode::Edit),
449 self.virtual_document.lines(),
450 &self.text_buffer,
451 );
452 }
453 }
454 }
455
456 pub fn resize_viewport(&mut self, size: Size) {
457 if self.viewport.size_changed(size) {
458 use cursor::Message::*;
459
460 let current_block_idx = self.editing_block;
461
462 self.virtual_document.layout(
463 &self.filename,
464 &self.content,
465 &self.view,
466 current_block_idx,
467 self.cursor.source_offset(),
468 &self.ast_nodes,
469 size.width.into(),
470 self.viewport.left().into(),
471 self.text_buffer.clone(),
472 );
473
474 self.viewport.resize(size);
475
476 self.cursor.update(
477 Jump(self.cursor.source_offset()),
478 self.virtual_document.lines(),
479 &self.text_buffer,
480 );
481
482 self.ensure_cursor_visible();
483 }
484 }
485
486 fn cursor_screen_row(&self) -> i32 {
488 (self.cursor.virtual_row() + self.virtual_document.meta().len()) as i32
489 }
490
491 fn ensure_cursor_visible(&mut self) {
495 let cursor_row = self.cursor_screen_row();
496 let cursor_column = self.cursor.virtual_column() as i32;
497
498 let vertical = if cursor_row < self.viewport.top() as i32 {
499 cursor_row - self.viewport.top() as i32
500 } else if cursor_row >= self.viewport.bottom() as i32 {
501 cursor_row - self.viewport.bottom() as i32 + 1
502 } else {
503 0
504 };
505
506 let line_width = self
507 .virtual_document
508 .lines()
509 .get(self.cursor.virtual_row())
510 .map(|line| {
511 line.virtual_spans()
512 .iter()
513 .map(|span| span.width())
514 .sum::<usize>()
515 })
516 .unwrap_or(0);
517
518 let horizontal = if line_width <= self.viewport.width as usize {
519 -(self.viewport.left() as i32)
520 } else if cursor_column < self.viewport.left() as i32 {
521 cursor_column - self.viewport.left() as i32
522 } else if cursor_column >= self.viewport.right() as i32 {
523 cursor_column - self.viewport.right() as i32 + 1
524 } else {
525 0
526 };
527
528 if (vertical, horizontal) != (0, 0) {
529 self.viewport.scroll_by((vertical, horizontal));
530 }
531 }
532
533 fn scroll_cursor_to_top(&mut self) {
536 let cursor_row = self.cursor_screen_row();
537 let total_rows =
538 (self.virtual_document.meta().len() + self.virtual_document.lines().len()) as i32;
539
540 let max_top = (total_rows - self.viewport.height as i32).max(0);
541 let vertical = cursor_row.min(max_top) - self.viewport.top() as i32;
542 let horizontal = -(self.viewport.left() as i32);
543
544 if (vertical, horizontal) != (0, 0) {
545 self.viewport.scroll_by((vertical, horizontal));
546 }
547 }
548
549 pub fn set_active(&mut self, active: bool) {
550 self.active = active;
551 }
552
553 pub fn theme(&self) -> Theme {
554 self.theme
555 }
556
557 pub fn set_theme(&mut self, theme: &Theme) {
559 self.theme = *theme;
560 self.virtual_document.set_theme(theme);
561 self.update_layout();
562 }
563
564 pub fn modified(&self) -> bool {
565 self.modified || self.text_buffer().is_some_and(|buffer| buffer.modified)
566 }
567
568 pub fn selection(&self) -> Option<Selection> {
569 self.selection
570 }
571
572 pub fn is_selecting(&self) -> bool {
573 self.selection.is_some()
574 }
575
576 pub fn toggle_selection(&mut self, mode: SelectionMode) {
578 self.selection = match self.selection {
579 Some(selection) if selection.mode == mode => None,
580 _ => Some(Selection {
581 anchor: self.cursor.source_offset(),
582 mode,
583 }),
584 };
585 }
586
587 pub fn clear_selection(&mut self) {
588 self.selection = None;
589 }
590
591 pub fn has_pending_count(&self) -> bool {
592 self.pending_count.is_some()
593 }
594
595 pub fn push_count_digit(&mut self, digit: u8) {
596 self.pending_count = Some(
597 self.pending_count
598 .unwrap_or(0)
599 .saturating_mul(10)
600 .saturating_add(digit as usize),
601 );
602 }
603
604 pub fn take_count(&mut self) -> Option<usize> {
606 self.pending_count.take()
607 }
608
609 pub fn reset_count(&mut self) {
610 self.pending_count = None;
611 }
612
613 pub fn awaiting_find_target(&self) -> bool {
614 matches!(self.pending, Some(Pending::Find(_)))
615 }
616
617 pub fn arm_find(&mut self, direction: Direction, till: bool) {
618 self.pending = Some(Pending::Find(FindKind { direction, till }));
619 }
620
621 pub fn clear_pending_find(&mut self) {
622 if self.awaiting_find_target() {
623 self.pending = None;
624 }
625 }
626
627 pub fn take_pending_find(&mut self) -> Option<FindKind> {
628 match self.pending {
629 Some(Pending::Find(kind)) => {
630 self.pending = None;
631 Some(kind)
632 }
633 _ => None,
634 }
635 }
636
637 pub fn remember_find(&mut self, target: char, kind: FindKind) {
639 self.last_find = Some(FindMotion { target, kind });
640 }
641
642 pub fn last_find(&self) -> Option<(char, FindKind)> {
643 self.last_find.map(|find| (find.target, find.kind))
644 }
645
646 pub fn awaiting_text_object(&self) -> bool {
647 matches!(self.pending, Some(Pending::TextObject(_)))
648 }
649
650 pub fn arm_text_object(&mut self, kind: TextObjectKind) {
651 self.pending = Some(Pending::TextObject(kind));
652 }
653
654 pub fn take_text_object(&mut self) -> Option<TextObjectKind> {
655 match self.pending {
656 Some(Pending::TextObject(kind)) => {
657 self.pending = None;
658 Some(kind)
659 }
660 _ => None,
661 }
662 }
663
664 pub fn clear_pending_text_object(&mut self) {
665 if self.awaiting_text_object() {
666 self.pending = None;
667 }
668 }
669
670 pub fn awaiting_replace(&self) -> bool {
671 matches!(self.pending, Some(Pending::Replace))
672 }
673
674 pub fn arm_replace(&mut self) {
675 self.pending = Some(Pending::Replace);
676 }
677
678 pub fn clear_pending_replace(&mut self) {
679 if self.awaiting_replace() {
680 self.pending = None;
681 }
682 }
683
684 pub fn pending_operator(&self) -> Option<Operator> {
685 self.pending_operator
686 }
687
688 pub fn pending_hint(&self) -> String {
690 let count = self
691 .pending_count
692 .map(|c| c.to_string())
693 .unwrap_or_default();
694 let operator = match self.pending_operator {
695 Some(Operator::Delete) => "d",
696 Some(Operator::Change) => "c",
697 Some(Operator::Yank) => "y",
698 None => "",
699 };
700 format!("{count}{operator}")
701 }
702
703 pub fn set_operator(&mut self, operator: Operator) {
704 self.pending_operator = Some(operator);
705 }
706
707 pub fn take_operator(&mut self) -> Option<Operator> {
708 self.pending_operator.take()
709 }
710
711 pub fn clear_operator(&mut self) {
712 self.pending_operator = None;
713 }
714
715 pub fn register(&self) -> &Register {
716 &self.register
717 }
718
719 pub fn set_register(&mut self, text: String, linewise: bool) {
720 self.register = Register { text, linewise };
721 }
722
723 fn snapshot(&self) -> Snapshot {
724 Snapshot {
725 content: self.content.clone(),
726 offset: self.cursor.source_offset(),
727 }
728 }
729
730 pub fn mark_undo_point(&mut self) {
732 self.undo_stack.push(self.snapshot());
733 self.redo_stack.clear();
734 }
735
736 pub fn undo(&mut self) -> bool {
737 match self.undo_stack.pop() {
738 Some(previous) => {
739 self.redo_stack.push(self.snapshot());
740 self.restore(previous);
741 true
742 }
743 None => false,
744 }
745 }
746
747 pub fn redo(&mut self) -> bool {
748 match self.redo_stack.pop() {
749 Some(next) => {
750 self.undo_stack.push(self.snapshot());
751 self.restore(next);
752 true
753 }
754 None => false,
755 }
756 }
757
758 fn restore(&mut self, snapshot: Snapshot) {
759 self.content = snapshot.content;
760 self.ast_nodes = parser::from_str(&self.content);
761 self.modified = true;
762 self.text_buffer = None;
763 self.editing_block = None;
764 self.jump_to_offset(snapshot.offset.min(self.content.len()));
765 }
766
767 pub fn splice(&mut self, range: Range<usize>, replacement: &str) {
770 self.commit_text_buffer();
771 self.mark_undo_point();
772 self.content.replace_range(range.clone(), replacement);
773 self.ast_nodes = parser::from_str(&self.content);
774 self.modified = true;
775 self.text_buffer = None;
776 self.editing_block = None;
777 let target = (range.start + replacement.len()).min(self.content.len());
778 self.jump_to_offset(target);
779 }
780
781 pub fn paste(&mut self, after: bool) {
784 if self.register.text.is_empty() {
785 return;
786 }
787 let cursor = self.cursor.source_offset();
788
789 if self.register.linewise {
790 let insert_at = if after {
791 self.content[cursor..]
792 .find('\n')
793 .map_or(self.content.len(), |i| cursor + i + 1)
794 } else {
795 self.content[..cursor].rfind('\n').map_or(0, |i| i + 1)
796 };
797 let mut text = self.register.text.clone();
798 if !text.ends_with('\n') {
799 text.push('\n');
800 }
801 self.splice(insert_at..insert_at, &text);
802 let landing = self.content[insert_at..]
803 .char_indices()
804 .find(|&(_, c)| !c.is_whitespace())
805 .map_or(insert_at, |(i, _)| insert_at + i);
806 self.jump_to_offset(landing);
807 } else {
808 let char_len = self.content[cursor..]
809 .chars()
810 .next()
811 .map_or(0, char::len_utf8);
812 let insert_at = if after { cursor + char_len } else { cursor };
813 let text = self.register.text.clone();
814 self.splice(insert_at..insert_at, &text);
815 }
816 }
817
818 fn live_content(&self) -> Cow<'_, str> {
820 self.text_buffer
821 .as_ref()
822 .filter(|buffer| buffer.modified)
823 .map(|buffer| Cow::Owned(buffer.write(&self.content)))
824 .unwrap_or(Cow::Borrowed(&self.content))
825 }
826
827 pub fn selection_range(&self) -> Option<Range<usize>> {
830 let selection = self.selection?;
831 let content = self.live_content();
832 let cursor = self.cursor.source_offset().min(content.len());
833 let anchor = selection.anchor.min(content.len());
834 let (lo, hi) = (anchor.min(cursor), anchor.max(cursor));
835
836 let range = match selection.mode {
837 SelectionMode::Char => {
838 let end = hi + content[hi..].chars().next().map_or(0, char::len_utf8);
839 lo..end
840 }
841 SelectionMode::Line => {
842 let start = content[..lo].rfind('\n').map_or(0, |i| i + 1);
843 let end = content[hi..]
844 .find('\n')
845 .map_or(content.len(), |i| hi + i + 1);
846 start..end
847 }
848 };
849
850 Some(range)
851 }
852
853 pub fn selected_text(&self) -> Option<String> {
854 let range = self.selection_range()?;
855 self.live_content().get(range).map(str::to_string)
856 }
857
858 pub fn flash_yank(&mut self, range: Range<usize>) {
861 self.yank_flash = Some(YankFlash {
862 range,
863 started: Instant::now(),
864 });
865 }
866
867 pub fn yank_flash_range(&self) -> Option<Range<usize>> {
869 self.yank_flash
870 .as_ref()
871 .filter(|flash| flash.started.elapsed() < YANK_FLASH_DURATION)
872 .map(|flash| flash.range.clone())
873 }
874
875 pub fn cursor_left(&mut self, amount: usize) {
876 use cursor::Message::*;
877
878 let prev_block_idx = self.current_block_idx();
879 self.cursor.update(
880 MoveLeft(amount),
881 self.virtual_document.lines(),
882 &self.text_buffer,
883 );
884
885 self.relayout_on_block_change(prev_block_idx);
886 self.ensure_cursor_visible();
887 }
888
889 pub fn cursor_right(&mut self, amount: usize) {
890 use cursor::Message::*;
891
892 let prev_block_idx = self.current_block_idx();
893 self.cursor.update(
894 MoveRight(amount),
895 self.virtual_document.lines(),
896 &self.text_buffer,
897 );
898
899 self.relayout_on_block_change(prev_block_idx);
900 self.ensure_cursor_visible();
901 }
902
903 pub fn cursor_to_end(&mut self) {
904 let last_block = self.virtual_document.blocks().len().saturating_sub(1);
905 self.cursor_jump(last_block);
906 self.cursor_down(usize::MAX);
909 }
910
911 pub fn cursor_jump(&mut self, idx: usize) {
912 let prev_block_idx = self.current_block_idx();
913
914 if let Some(block) = self.virtual_document.blocks().get(idx) {
915 self.cursor.update(
916 cursor::Message::Jump(block.source_range.start),
917 self.virtual_document.lines(),
918 &self.text_buffer,
919 );
920 }
921
922 self.relayout_on_block_change(prev_block_idx);
923 self.scroll_cursor_to_top();
924 }
925
926 pub fn jump_to_offset(&mut self, offset: usize) {
931 let offset = cursor::snap_to_char_boundary(&self.content, offset);
932
933 if matches!(self.view, View::Edit(..)) {
934 let target_block = self
935 .ast_nodes
936 .iter()
937 .position(|node| node.source_range().contains(&offset))
938 .or_else(|| {
939 self.ast_nodes
940 .iter()
941 .position(|node| node.source_range().start >= offset)
942 })
943 .or_else(|| {
944 self.ast_nodes
945 .iter()
946 .rposition(|node| node.source_range().end <= offset)
947 });
948 if let Some(block) = target_block {
949 if self.editing_block != Some(block) {
950 self.enter_insert(block);
951 }
952 if let Some(start) = self.text_buffer.as_ref().map(|b| b.source_range.start) {
957 let end = self
958 .text_buffer
959 .as_ref()
960 .map_or(start, |b| b.source_range.end);
961 let gap_is_blank = self
962 .content
963 .get(offset..start)
964 .is_some_and(|gap| gap.chars().all(|c| c == ' ' || c == '\t'));
965 if offset < start && gap_is_blank {
966 if let Some(text) = self.content.get(offset..end) {
967 self.text_buffer = Some(TextBuffer::new(text, offset..end));
968 }
969 }
970 }
971 }
972 }
973
974 self.cursor.update(
975 cursor::Message::Jump(offset),
976 self.virtual_document.lines(),
977 &self.text_buffer,
978 );
979 self.update_layout();
980 self.ensure_cursor_visible();
981 }
982
983 pub fn update_layout(&mut self) {
984 use cursor::Message::*;
985
986 if matches!(self.view, View::Edit(..)) && self.text_buffer.is_none() {
998 let offset = self.cursor.source_offset();
999 let block_idx = self
1000 .ast_nodes
1001 .iter()
1002 .position(|node| node.source_range().contains(&offset))
1003 .or_else(|| {
1004 self.ast_nodes
1005 .iter()
1006 .rposition(|node| node.source_range().end <= offset)
1007 })
1008 .unwrap_or_else(|| self.current_block_idx());
1009 self.enter_insert(block_idx);
1010 self.cursor.update(
1011 SwitchMode(cursor::CursorMode::Edit),
1012 self.virtual_document.lines(),
1013 &self.text_buffer,
1014 );
1015 }
1016
1017 let current_block_idx = self.editing_block;
1018
1019 self.virtual_document.layout(
1020 &self.filename,
1021 &self.content,
1022 &self.view,
1023 current_block_idx,
1024 self.cursor.source_offset(),
1025 &self.ast_nodes,
1026 self.viewport.area().width.into(),
1027 self.viewport.left().into(),
1028 self.text_buffer.clone(),
1029 );
1030
1031 self.cursor.update(
1032 Jump(self.cursor.source_offset()),
1033 self.virtual_document.lines(),
1034 &self.text_buffer,
1035 );
1036 }
1037
1038 pub fn cursor_up(&mut self, amount: usize) {
1039 let prev_block_idx = self.current_block_idx();
1040 let prev_row = self.cursor.virtual_row();
1041
1042 self.cursor.update(
1043 cursor::Message::MoveUp(amount),
1044 self.virtual_document.lines(),
1045 &self.text_buffer,
1046 );
1047 let consumed = prev_row.saturating_sub(self.cursor.virtual_row());
1048 self.viewport.scroll_up(amount.saturating_sub(consumed));
1049
1050 self.relayout_on_block_change(prev_block_idx);
1051 self.ensure_cursor_visible();
1052 }
1053
1054 pub fn cursor_down(&mut self, amount: usize) {
1055 let prev_block_idx = self.current_block_idx();
1056
1057 self.cursor.update(
1058 cursor::Message::MoveDown(amount),
1059 self.virtual_document.lines(),
1060 &self.text_buffer,
1061 );
1062
1063 self.relayout_on_block_change(prev_block_idx);
1064 self.ensure_cursor_visible();
1065 }
1066
1067 fn relayout_on_block_change(&mut self, prev_block_idx: usize) {
1080 if !matches!(self.view, View::Edit(..)) {
1081 return;
1082 }
1083
1084 let target_block_idx = self.current_block_idx();
1085 if target_block_idx == prev_block_idx {
1086 self.update_layout();
1087 return;
1088 }
1089
1090 let adjacent = prev_block_idx.abs_diff(target_block_idx) == 1;
1091 let moved_up = target_block_idx < prev_block_idx;
1092 let use_end = adjacent && moved_up;
1093
1094 let target_offset = self.ast_nodes.get(target_block_idx).map(|node| {
1095 let range = node.source_range();
1096 if use_end {
1097 range.end.saturating_sub(1).max(range.start)
1098 } else {
1099 range.start
1100 }
1101 });
1102
1103 self.enter_insert(target_block_idx);
1104
1105 self.virtual_document.layout(
1106 &self.filename,
1107 &self.content,
1108 &self.view,
1109 self.editing_block,
1110 target_offset.unwrap_or_else(|| self.cursor.source_offset()),
1111 &self.ast_nodes,
1112 self.viewport.area().width.into(),
1113 self.viewport.left().into(),
1114 self.text_buffer.clone(),
1115 );
1116
1117 if let Some(offset) = target_offset {
1118 self.cursor.update(
1119 cursor::Message::Jump(offset),
1120 self.virtual_document.lines(),
1121 &self.text_buffer,
1122 );
1123 }
1124 }
1125
1126 pub fn save_to_file(&mut self) -> io::Result<()> {
1127 if self.modified() {
1128 let mut file = File::create(&self.filepath)?;
1129 file.write_all(self.content.as_bytes())?;
1130 self.modified = false;
1131 }
1132 Ok(())
1133 }
1134
1135 fn shift_source_ranges(&mut self, offset: usize, shift: isize) {
1137 self.ast_nodes
1138 .iter_mut()
1139 .for_each(|node| shift_node(node, offset, shift));
1140 }
1141}
1142
1143fn shift_node(node: &mut ast::Node, offset: usize, shift: isize) {
1149 let shift_value = |v: usize| v.checked_add_signed(shift).unwrap_or(0);
1150 let range = node.source_range();
1151
1152 if range.end <= offset {
1153 return;
1154 }
1155
1156 let shifted_range = if range.start > offset {
1157 shift_value(range.start)..shift_value(range.end)
1158 } else {
1159 range.start..shift_value(range.end)
1160 };
1161 node.set_source_range(shifted_range);
1162
1163 if let Some(children) = node.children_as_mut() {
1164 children
1165 .iter_mut()
1166 .for_each(|child| shift_node(child, offset, shift));
1167 }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173 use ratatui::layout::Size;
1174 use std::path::Path;
1175
1176 fn assert_cursor_visible(state: &NoteEditorState, context: &str) {
1177 let cursor_screen_row = state.cursor_screen_row();
1178 let top = state.viewport().top() as i32;
1179 let bottom = state.viewport().bottom() as i32;
1180 assert!(
1181 cursor_screen_row >= top && cursor_screen_row < bottom,
1182 "{context}: cursor screen row {cursor_screen_row} outside viewport [{top}, {bottom})",
1183 );
1184
1185 let cursor_column = state.cursor.virtual_column() as i32;
1186 let left = state.viewport().left() as i32;
1187 let right = state.viewport().right() as i32;
1188 assert!(
1189 cursor_column >= left && cursor_column < right,
1190 "{context}: cursor column {cursor_column} outside viewport [{left}, {right})",
1191 );
1192 }
1193
1194 fn line_texts(state: &NoteEditorState) -> Vec<String> {
1195 state
1196 .virtual_document
1197 .lines()
1198 .iter()
1199 .map(|line| {
1200 line.clone()
1201 .spans()
1202 .iter()
1203 .map(|span| span.content.to_string())
1204 .collect()
1205 })
1206 .collect()
1207 }
1208
1209 #[test]
1213 fn test_block_quote_raw_marker_line_by_line() {
1214 let mut state = NoteEditorState::new(
1215 "> quote line one\n> quote line two\n",
1216 "test",
1217 Path::new("test.md"),
1218 &Symbols::unicode(),
1219 );
1220 state.resize_viewport(Size::new(50, 14));
1221
1222 let read = line_texts(&state);
1223 assert!(
1224 read.iter().any(|line| line.contains("┃ quote line one")),
1225 "read mode renders the quote marker, got {read:?}",
1226 );
1227
1228 state.set_view(View::Edit(EditMode::Source));
1229 let edit = line_texts(&state);
1230 assert!(
1232 edit.iter().any(|line| line.contains("> quote line one")),
1233 "cursor line shows the raw marker, got {edit:?}",
1234 );
1235 assert!(
1236 edit.iter().any(|line| line.contains("┃ quote line two")),
1237 "non-cursor line keeps the rendered marker, got {edit:?}",
1238 );
1239
1240 state.cursor_down(1);
1242 let edit = line_texts(&state);
1243 assert!(
1244 edit.iter().any(|line| line.contains("┃ quote line one")),
1245 "first line now rendered, got {edit:?}",
1246 );
1247 assert!(
1248 edit.iter().any(|line| line.contains("> quote line two")),
1249 "second line now raw, got {edit:?}",
1250 );
1251 }
1252
1253 #[test]
1256 fn test_callout_renders_icon_and_label() {
1257 use ratatui::style::Color;
1258
1259 let mut state = NoteEditorState::new(
1260 "> [!warning]\n> Mind the gap.\n",
1261 "test",
1262 Path::new("test.md"),
1263 &Symbols::unicode(),
1264 );
1265 state.resize_viewport(Size::new(50, 14));
1266
1267 let read = line_texts(&state);
1268 assert!(
1269 read.iter().any(|line| line.contains("⚠ Warning")),
1270 "callout header shows the icon and label, got {read:?}",
1271 );
1272 assert!(
1273 read.iter().any(|line| line.contains("Mind the gap.")),
1274 "callout body follows the header, got {read:?}",
1275 );
1276
1277 let header_colored = state
1278 .virtual_document
1279 .lines()
1280 .iter()
1281 .flat_map(|line| line.clone().spans())
1282 .any(|span| span.content.contains("Warning") && span.style.fg == Some(Color::Yellow));
1283 assert!(header_colored, "warning callout header is yellow");
1284 }
1285
1286 #[test]
1290 fn test_obsidian_callout_with_title() {
1291 let mut state = NoteEditorState::new(
1292 "> [!note]- A word on moving your vault folder\n> Body text.\n",
1293 "test",
1294 Path::new("test.md"),
1295 &Symbols::unicode(),
1296 );
1297 state.resize_viewport(Size::new(60, 14));
1298
1299 let read = line_texts(&state);
1300 assert!(
1301 read.iter()
1302 .any(|line| line.contains("✎ A word on moving your vault folder")),
1303 "callout header shows the custom title, got {read:?}",
1304 );
1305 assert!(
1306 read.iter().any(|line| line.contains("Body text.")),
1307 "callout body follows the header, got {read:?}",
1308 );
1309 assert!(
1310 !read.iter().any(|line| line.contains("[!note]")),
1311 "raw marker line must not leak into the body, got {read:?}",
1312 );
1313 }
1314
1315 #[test]
1318 fn test_obsidian_callout_aliases_and_types() {
1319 let mut state = NoteEditorState::new(
1320 "> [!summary] Overview\n> Body.\n\n> [!bug]\n> Squashed.\n",
1321 "test",
1322 Path::new("test.md"),
1323 &Symbols::unicode(),
1324 );
1325 state.resize_viewport(Size::new(60, 20));
1326
1327 let read = line_texts(&state);
1328 assert!(
1329 read.iter().any(|line| line.contains("▤ Overview")),
1330 "summary alias renders as an abstract callout, got {read:?}",
1331 );
1332 assert!(
1333 read.iter().any(|line| line.contains("⊙ Bug")),
1334 "bug is a first-class Obsidian callout type, got {read:?}",
1335 );
1336 }
1337
1338 #[test]
1341 fn test_callout_keeps_color_when_editing() {
1342 use ratatui::style::Color;
1343
1344 let mut state = NoteEditorState::new(
1345 "> [!warning]\n> Mind the gap.\n",
1346 "test",
1347 Path::new("test.md"),
1348 &Symbols::unicode(),
1349 );
1350 state.resize_viewport(Size::new(50, 14));
1351 state.set_view(View::Edit(EditMode::Source));
1352
1353 let bars: Vec<_> = state
1354 .virtual_document
1355 .lines()
1356 .iter()
1357 .flat_map(|line| line.clone().spans())
1358 .filter(|span| span.content.contains('▌') || span.content.contains('>'))
1359 .collect();
1360 assert!(!bars.is_empty(), "callout bars/markers should be present");
1361 assert!(
1362 bars.iter().all(|span| span.style.fg == Some(Color::Yellow)),
1363 "callout keeps its yellow accent while editing, got {bars:?}",
1364 );
1365 }
1366
1367 #[test]
1370 fn test_block_quote_cursor_marker_is_colored() {
1371 use ratatui::style::Color;
1372
1373 let mut state = NoteEditorState::new(
1374 "> > deep\n",
1375 "test",
1376 Path::new("test.md"),
1377 &Symbols::unicode(),
1378 );
1379 state.resize_viewport(Size::new(40, 8));
1380 state.set_view(View::Edit(EditMode::Source)); let markers: Vec<_> = state
1383 .virtual_document
1384 .lines()
1385 .iter()
1386 .flat_map(|line| line.clone().spans())
1387 .filter(|span| span.content.contains('>'))
1388 .collect();
1389 assert!(!markers.is_empty(), "quote markers should be present");
1390 assert!(
1391 markers
1392 .iter()
1393 .all(|span| span.style.fg == Some(Color::Magenta)),
1394 "every `>` marker (all nesting levels) should be coloured",
1395 );
1396 }
1397
1398 #[test]
1401 fn test_code_block_content_not_decorated_when_editing() {
1402 let mut state = NoteEditorState::new(
1403 "```\n- not a list\n# not a heading\n```\n",
1404 "test",
1405 Path::new("test.md"),
1406 &Symbols::unicode(),
1407 );
1408 state.resize_viewport(Size::new(40, 10));
1409 state.set_view(View::Edit(EditMode::Source));
1410
1411 let lines = line_texts(&state);
1412 assert!(
1413 lines.iter().any(|line| line.contains("- not a list")),
1414 "code content stays literal, got {lines:?}",
1415 );
1416 assert!(
1417 !lines.iter().any(|line| line.contains('●')),
1418 "code content must not get a list bullet, got {lines:?}",
1419 );
1420 assert!(
1421 lines.iter().any(|line| line.contains("# not a heading")),
1422 "code content keeps its `#`, got {lines:?}",
1423 );
1424 }
1425
1426 #[test]
1429 fn test_heading_keeps_underline_when_editing() {
1430 let mut state = NoteEditorState::new(
1431 "## Title\n\npara\n",
1432 "test",
1433 Path::new("test.md"),
1434 &Symbols::unicode(),
1435 );
1436 state.resize_viewport(Size::new(40, 10));
1437 state.set_view(View::Edit(EditMode::Source)); let lines = line_texts(&state);
1440 assert!(
1441 lines.iter().any(|line| line.contains("## Title")),
1442 "heading markers stay visible, got {lines:?}",
1443 );
1444 assert!(
1445 lines
1446 .iter()
1447 .any(|line| !line.is_empty() && line.chars().all(|c| c == '─')),
1448 "heading keeps its underline, got {lines:?}",
1449 );
1450 }
1451
1452 #[test]
1456 fn test_multiple_blank_lines_after_active_block_render() {
1457 let mut state = NoteEditorState::new(
1459 "- item one\n\n\n\npara2\n",
1460 "test",
1461 Path::new("test.md"),
1462 &Symbols::unicode(),
1463 );
1464 state.resize_viewport(Size::new(50, 14));
1465 state.set_view(View::Edit(EditMode::Source));
1466
1467 let lines = line_texts(&state);
1468 let item = lines
1469 .iter()
1470 .position(|line| line.contains("item one"))
1471 .unwrap();
1472 let para = lines
1473 .iter()
1474 .position(|line| line.contains("para2"))
1475 .unwrap();
1476 assert_eq!(
1477 para - item,
1478 4,
1479 "expected three blank lines between the list and the paragraph, got {lines:?}",
1480 );
1481 }
1482
1483 #[test]
1487 fn test_merge_into_previous_block_keeps_text() {
1488 let mut state = NoteEditorState::new(
1489 "- item one\n\nsecond paragraph\n",
1490 "test",
1491 Path::new("test.md"),
1492 &Symbols::unicode(),
1493 );
1494 state.resize_viewport(Size::new(50, 12));
1495 state.set_view(View::Edit(EditMode::Source));
1496
1497 state.cursor_down(1);
1499 state.cursor_down(1);
1500 assert_eq!(state.cursor.source_offset(), 12);
1501 state.delete_char();
1502
1503 let lines = line_texts(&state);
1504 assert!(
1505 lines.iter().any(|line| line.contains("second paragraph")),
1506 "merged text must stay visible, got {lines:?}",
1507 );
1508 state.commit_text_buffer();
1509 assert_eq!(state.content, "- item one\nsecond paragraph\n");
1510 }
1511
1512 #[test]
1516 fn test_empty_list_item_renders_marker() {
1517 let mut state = NoteEditorState::new(
1518 "- one\n- \n- three\n",
1519 "test",
1520 Path::new("test.md"),
1521 &Symbols::unicode(),
1522 );
1523 state.resize_viewport(Size::new(40, 12));
1524 state.set_view(View::Edit(EditMode::Source));
1525
1526 let lines = line_texts(&state);
1528 assert!(
1529 lines.iter().any(|line| line == "● "),
1530 "empty item must render its marker, got {lines:?}",
1531 );
1532 assert!(lines.iter().any(|line| line.contains("three")), "{lines:?}");
1533 }
1534
1535 #[test]
1538 fn test_cursor_column_aligns_on_tab_indented_line() {
1539 let mut state = NoteEditorState::new(
1540 "- a\n\t- b\n",
1541 "test",
1542 Path::new("test.md"),
1543 &Symbols::unicode(),
1544 );
1545 state.resize_viewport(Size::new(40, 12));
1546 state.set_view(View::Edit(EditMode::Source));
1547 state.cursor_down(1); assert_eq!(state.cursor.source_offset(), 7);
1552 assert_eq!(state.cursor.virtual_column(), 4);
1553
1554 state.cursor_left(2);
1556 assert_eq!(state.cursor.source_offset(), 5); assert_eq!(state.cursor.virtual_column(), 2); }
1559
1560 #[test]
1564 fn test_tab_indented_nested_list_keeps_indentation_when_editing() {
1565 let mut state = NoteEditorState::new(
1566 "1. one\n2. two\n\t- nested\n\t\t- deep\n",
1567 "test",
1568 Path::new("test.md"),
1569 &Symbols::unicode(),
1570 );
1571 state.resize_viewport(Size::new(50, 12));
1572 state.set_view(View::Edit(EditMode::Source));
1573 let lines = line_texts(&state);
1575 assert!(lines.iter().any(|line| line == " ○ nested"), "{lines:?}");
1576 assert!(lines.iter().any(|line| line == " ◆ deep"), "{lines:?}");
1577 }
1578
1579 #[test]
1583 fn test_nested_list_renders_cleanly_when_editing() {
1584 let mut state = NoteEditorState::new(
1585 "- item one\n - nested one\n - nested two\n",
1586 "test",
1587 Path::new("test.md"),
1588 &Symbols::unicode(),
1589 );
1590 state.resize_viewport(Size::new(50, 12));
1591 state.set_view(View::Edit(EditMode::Source));
1592
1593 let lines = line_texts(&state);
1594 assert_eq!(lines.first().map(String::as_str), Some("- item one"));
1596 assert!(
1598 lines.iter().any(|line| line == " ○ nested one"),
1599 "{lines:?}"
1600 );
1601 assert!(
1602 lines.iter().any(|line| line == " ○ nested two"),
1603 "{lines:?}"
1604 );
1605 assert!(
1607 !lines
1608 .iter()
1609 .any(|line| !line.is_empty() && line.trim().is_empty()),
1610 "spurious whitespace line: {lines:?}",
1611 );
1612 }
1613
1614 #[test]
1618 fn test_jump_to_offset_to_list_keeps_markers() {
1619 let mut state = NoteEditorState::new(
1620 "# Title\n\n- alpha bravo\n- charlie delta\n",
1621 "test",
1622 Path::new("test.md"),
1623 &Symbols::unicode(),
1624 );
1625 state.set_vim_mode(true);
1626 state.resize_viewport(Size::new(40, 12));
1627 state.set_view(View::Edit(EditMode::Source));
1628
1629 let charlie = state.content.find("charlie").unwrap();
1630 state.jump_to_offset(charlie);
1631
1632 let lines = line_texts(&state);
1633 assert!(
1634 lines.iter().any(|line| line == "- charlie delta"),
1635 "cursor line is raw: {lines:?}",
1636 );
1637 assert!(
1638 lines.iter().any(|line| line == "● alpha bravo"),
1639 "other item keeps its rendered bullet: {lines:?}",
1640 );
1641 }
1642
1643 #[test]
1646 fn test_delete_blank_before_item_keeps_item_intact() {
1647 let mut state = NoteEditorState::new(
1648 "- first\n\n- second item text\n",
1649 "test",
1650 Path::new("test.md"),
1651 &Symbols::unicode(),
1652 );
1653 state.resize_viewport(Size::new(50, 12));
1654 state.set_view(View::Edit(EditMode::Source));
1655
1656 state.cursor_down(1);
1658 state.delete_char();
1659
1660 let lines = line_texts(&state);
1661 assert!(
1662 lines.iter().any(|line| line.contains("second item text")),
1663 "item must stay intact on one row, got {lines:?}",
1664 );
1665 state.commit_text_buffer();
1666 assert_eq!(state.content, "- first\n- second item text\n");
1667 }
1668
1669 #[test]
1675 fn test_loose_list_blank_not_doubled_when_item_raw() {
1676 let mut state = NoteEditorState::new(
1677 "- a\n\n- b\n",
1678 "test",
1679 Path::new("test.md"),
1680 &Symbols::unicode(),
1681 );
1682 state.resize_viewport(Size::new(40, 12));
1683 state.set_view(View::Edit(EditMode::Source));
1684
1685 let lines = line_texts(&state);
1687 let a = lines.iter().position(|line| line.contains("- a")).unwrap();
1688 let b = lines.iter().position(|line| line.contains("b")).unwrap();
1689 assert_eq!(
1690 b - a,
1691 2,
1692 "expected exactly one blank line between items, got {lines:?}",
1693 );
1694
1695 state.cursor_down(1);
1697 assert_eq!(
1698 state.cursor.source_offset(),
1699 4,
1700 "cursor should land on the blank line between the items",
1701 );
1702 state.delete_char();
1703 state.commit_text_buffer();
1704 assert_eq!(state.content, "- a\n- b\n");
1705 }
1706
1707 #[test]
1710 fn test_newline_at_start_of_list_item_preserves_text() {
1711 let mut state = NoteEditorState::new(
1712 "- hello world\n",
1713 "test",
1714 Path::new("test.md"),
1715 &Symbols::unicode(),
1716 );
1717 state.resize_viewport(Size::new(40, 10));
1718 state.set_view(View::Edit(EditMode::Source));
1719
1720 state.insert_char('\n');
1721
1722 let lines = line_texts(&state);
1723 assert!(
1724 lines.iter().any(|line| line.contains("- hello world")),
1725 "item text must survive the newline, got {lines:?}",
1726 );
1727 assert_eq!(state.cursor.source_offset(), 1);
1729 assert_eq!(state.cursor.virtual_row(), 1);
1730 assert_eq!(state.cursor.virtual_column(), 0);
1731 }
1732
1733 #[test]
1734 fn test_viewport_scrolls_with_cursor_in_edit_mode() {
1735 let content = "# Title\n\nLine 1\n\nLine 2\n\nLine 3\n\nLine 4\n\nLine 5\n";
1736
1737 let mut state =
1738 NoteEditorState::new(content, "test", Path::new("test.md"), &Symbols::unicode());
1739 state.resize_viewport(Size::new(40, 4));
1740
1741 state.cursor_down(2);
1742 state.set_view(View::Edit(EditMode::Source));
1743
1744 state.insert_char('\n');
1745 state.insert_char('\n');
1746 state.insert_char('\n');
1747 state.insert_char('\n');
1748 assert_cursor_visible(&state, "after insert_char");
1749
1750 state.cursor_right(20);
1751 assert_cursor_visible(&state, "after cursor_right");
1752
1753 state.cursor_left(20);
1754 assert_cursor_visible(&state, "after cursor_left");
1755
1756 state.cursor_down(5);
1757 assert_cursor_visible(&state, "after cursor_down");
1758
1759 state.cursor_up(5);
1760 assert_cursor_visible(&state, "after cursor_up");
1761 }
1762
1763 #[test]
1764 fn test_jump_to_heading_scrolls_it_to_top() {
1765 let filler = "\nparagraph\n".repeat(10);
1768 let content = format!("# Intro\n{filler}# Target\n{filler}");
1769
1770 let mut state =
1771 NoteEditorState::new(&content, "test", Path::new("test.md"), &Symbols::unicode());
1772 state.resize_viewport(Size::new(40, 6));
1773
1774 let target_offset = content.find("# Target").unwrap();
1775 let target_block = state
1776 .virtual_document
1777 .blocks()
1778 .iter()
1779 .position(|block| block.source_range().contains(&target_offset))
1780 .unwrap();
1781
1782 state.cursor_jump(target_block);
1783
1784 assert_eq!(
1785 state.cursor_screen_row(),
1786 state.viewport().top() as i32,
1787 "heading should sit at the top of the viewport",
1788 );
1789 }
1790
1791 #[test]
1792 fn test_viewport_scrolls_horizontally_on_long_code_line() {
1793 let long = "x".repeat(100);
1796 let content = format!("```\n{long}\n```\n");
1797
1798 let mut state =
1799 NoteEditorState::new(&content, "test", Path::new("test.md"), &Symbols::unicode());
1800 state.resize_viewport(Size::new(20, 10));
1801 state.set_view(View::Edit(EditMode::Source));
1802
1803 state.cursor_down(1);
1805 assert_eq!(state.viewport().left(), 0, "no scroll at line start");
1806
1807 state.cursor_right(80);
1808 let panned = state.viewport().left();
1809 assert!(panned > 0, "viewport should pan right to follow the cursor");
1810 assert_cursor_visible(&state, "after cursor_right on long line");
1811
1812 state.cursor_left(80);
1813 assert!(
1814 state.viewport().left() < panned,
1815 "viewport should pan back toward the start",
1816 );
1817 assert_cursor_visible(&state, "after cursor_left on long line");
1818 }
1819
1820 fn widest_line_in_range(state: &NoteEditorState, range: std::ops::Range<usize>) -> usize {
1821 state
1822 .virtual_document
1823 .lines()
1824 .iter()
1825 .filter(|line| {
1826 line.source_range()
1827 .is_some_and(|r| range.contains(&r.start))
1828 })
1829 .map(|line| line.virtual_spans().iter().map(|span| span.width()).sum())
1830 .max()
1831 .expect("a line in the given source range")
1832 }
1833
1834 #[test]
1835 fn test_code_background_extends_past_horizontal_scroll() {
1836 let content = format!("```\nshort\n{}\n```\n", "x".repeat(100));
1840
1841 let mut state =
1842 NoteEditorState::new(&content, "", Path::new("test.md"), &Symbols::unicode());
1843 let width = 20;
1844 state.resize_viewport(Size::new(width, 10));
1845 state.set_view(View::Edit(EditMode::Source));
1846
1847 state.cursor_down(2);
1849 state.cursor_right(80);
1850 state.update_layout(); let left = state.viewport().left() as usize;
1853 assert!(left > 0, "expected a horizontal scroll");
1854
1855 let short_line_width = widest_line_in_range(&state, 4..10);
1856 assert!(
1857 short_line_width >= left + width as usize,
1858 "code background ({short_line_width}) must cover the viewport \
1859 ({left} + {width})",
1860 );
1861 }
1862
1863 #[test]
1864 fn test_non_active_block_fills_extend_past_horizontal_scroll() {
1865 let long = "x".repeat(100);
1869 let content = format!("```\n{long}\n```\n\n```\nbbb\n```\n");
1870 let second_block_start = content.find("\n\n").unwrap() + 2;
1872
1873 let mut state =
1874 NoteEditorState::new(&content, "", Path::new("test.md"), &Symbols::unicode());
1875 let width = 20;
1876 state.resize_viewport(Size::new(width, 10));
1877 state.set_view(View::Edit(EditMode::Source));
1878
1879 state.cursor_down(1);
1881 state.cursor_right(80);
1882 state.update_layout();
1883
1884 let left = state.viewport().left() as usize;
1885 assert!(left > 0, "expected a horizontal scroll");
1886
1887 let non_active_width = widest_line_in_range(&state, second_block_start..content.len());
1888 assert!(
1889 non_active_width >= left + width as usize,
1890 "non-active code background ({non_active_width}) must cover the \
1891 viewport ({left} + {width})",
1892 );
1893 }
1894
1895 fn edit_state(content: &str) -> NoteEditorState<'static> {
1896 let mut state =
1897 NoteEditorState::new(content, "test", Path::new("test.md"), &Symbols::unicode());
1898 state.resize_viewport(Size::new(40, 10));
1899 state.set_view(View::Edit(EditMode::Source));
1900 state
1901 }
1902
1903 #[test]
1904 fn test_typing_wrapping_paragraph_never_pans_horizontally() {
1905 let mut state =
1906 NoteEditorState::new("hello\n", "test", Path::new("test.md"), &Symbols::unicode());
1907 state.resize_viewport(Size::new(12, 20));
1908 state.set_view(View::Edit(EditMode::Source));
1909 state.cursor_right(100);
1910
1911 for c in " world foobar".chars() {
1912 state.insert_char(c);
1913 assert_eq!(
1914 state.viewport().left(),
1915 0,
1916 "wrapped text must not pan; left stayed non-zero after typing {c:?}",
1917 );
1918 }
1919
1920 assert!(
1921 state.cursor.virtual_row() > 0,
1922 "the word should have wrapped"
1923 );
1924 assert_cursor_visible(&state, "after the word wrapped");
1925 }
1926
1927 #[test]
1928 fn test_charwise_selection_is_inclusive() {
1929 let mut state = edit_state("hello world\n");
1930
1931 state.toggle_selection(SelectionMode::Char);
1932 state.cursor_right(4);
1933
1934 assert_eq!(state.selected_text().as_deref(), Some("hello"));
1935 }
1936
1937 #[test]
1938 fn test_charwise_selection_extends_backwards() {
1939 let mut state = edit_state("hello world\n");
1940
1941 state.cursor_right(4);
1942 state.toggle_selection(SelectionMode::Char);
1943 state.cursor_left(4);
1944
1945 assert_eq!(state.selected_text().as_deref(), Some("hello"));
1946 }
1947
1948 #[test]
1949 fn test_linewise_selection_covers_whole_line() {
1950 let mut state = edit_state("line one\nline two\n");
1951
1952 state.cursor_right(3);
1953 state.toggle_selection(SelectionMode::Line);
1954
1955 assert_eq!(state.selected_text().as_deref(), Some("line one\n"));
1956 }
1957
1958 #[test]
1959 fn test_toggle_same_mode_clears_selection() {
1960 let mut state = edit_state("hello\n");
1961
1962 state.toggle_selection(SelectionMode::Char);
1963 assert!(state.is_selecting());
1964
1965 state.toggle_selection(SelectionMode::Char);
1966 assert!(!state.is_selecting());
1967 assert_eq!(state.selection_range(), None);
1968 }
1969}