Skip to main content

basalt_tui/note_editor/
state.rs

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    /// Shows the markdown exactly as written
31    Source,
32    // TODO:
33    // /// Hides most of the markdown syntax
34    // LivePreview
35}
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/// `anchor` is the source byte offset where selection began; the moving end is
51/// the cursor's current source offset.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct Selection {
54    pub anchor: usize,
55    pub mode: SelectionMode,
56}
57
58/// An armed find (`f`/`F`/`t`/`T`) waiting for its target character.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct FindKind {
61    pub direction: Direction,
62    pub till: bool,
63}
64
65/// The last completed find, replayed by `;` and `,`.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67struct FindMotion {
68    target: char,
69    kind: FindKind,
70}
71
72/// A keypress the editor is armed to consume next: the target of a find, the
73/// object of a text object, or the replacement of `r`. At most one is armed.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75enum Pending {
76    Find(FindKind),
77    TextObject(TextObjectKind),
78    Replace,
79}
80
81/// A pending vim operator awaiting a motion (or a doubled key for linewise).
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Operator {
84    Delete,
85    Change,
86    Yank,
87}
88
89/// The unnamed register: text captured by `d`/`c`/`y`/`x`, pasted by `p`/`P`.
90#[derive(Clone, Debug, Default)]
91pub struct Register {
92    pub text: String,
93    pub linewise: bool,
94}
95
96/// A full-content undo point.
97#[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/// The editor's current mode, shown as the status-bar indicator.
121#[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    /// Indicator colour for the mode, from the active theme.
145    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    // FIXME: Use Rope instead of String for O(log n) instead of O(n).
158    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    /// Which block is currently in raw/edit mode. Stored explicitly so
184    /// the layout always matches the text_buffer, even when the cursor
185    /// position would temporarily resolve to a different block.
186    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    /// The current editor mode for the status-bar indicator.
244    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        // Commit any pending edits from the previous block before switching.
277        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            // Only create an empty node for genuinely empty files, not when
287            // blocks haven't been laid out yet.
288            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    /// Write the current text_buffer back to self.content if it was modified,
308    /// re-parse AST nodes. Returns Some if content changed.
309    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            // Shift source ranges of all nodes after the insertion point by the character's byte length
337            let char_byte_len = c.len_utf8();
338            self.shift_source_ranges(source_pos, char_byte_len as isize);
339
340            // Move the cursor to the inserted character before laying out so the
341            // active (raw) line tracks the cursor's new position.
342            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        // Comments for my own sanity :)
356        // 1. Check if we have a text buffer return if not
357        let buffer = self.text_buffer()?;
358
359        // 2. Check if we are at the start of range, which means we need to merge with previous
360        //    block
361        let at_buffer_start = buffer.source_range.start == self.cursor.source_offset();
362
363        // 3. Get previous and current block idx
364        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            // 4. Extend the current text buffer to contain the prev node
375            self.text_buffer
376                .as_mut()?
377                .insert_at_start(prev_start, prefix);
378            // 5. Set the previous block as the current editing block
379            self.editing_block = Some(previous_block_idx);
380            // 6. Delete current ast node
381            self.ast_nodes.remove(current_block_idx);
382        }
383
384        // 7. Get the buffer again since it might have been updated
385        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        // The removed byte sits just before the cursor; shift from there (not the
391        // cursor) so a node boundary ending at the cursor shrinks with it.
392        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        // Position the cursor where the deleted character was before laying out
396        // so the active (raw) line tracks the cursor's new position.
397        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                // Read mode never pans horizontally; reset so its width-sized
441                // fills (code backgrounds, rules) line up with the viewport.
442                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    /// Cursor row in viewport coordinates: its virtual row offset past the meta lines.
487    fn cursor_screen_row(&self) -> i32 {
488        (self.cursor.virtual_row() + self.virtual_document.meta().len()) as i32
489    }
490
491    /// Ensures the cursor is visible within the viewport by scrolling if necessary.
492    /// This method should be called after any operation that might cause the cursor
493    /// to move outside the visible area (e.g., resize, cursor movement).
494    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    /// Clamped so the last line never rises above the viewport bottom, leaving no
534    /// blank tail when the cursor is near the end of the document.
535    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    /// Swaps the colour theme and re-lays out so the new colours take effect.
558    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    /// Enters visual selection in `mode`, or exits if that mode is already active.
577    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    /// Consumes the pending count. `None` means no explicit count was given.
605    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    /// Records a completed find so `;` and `,` can replay it.
638    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    /// A short hint of the in-flight command (e.g. `3d`) for the mode indicator.
689    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    /// Records the current content as an undo point, discarding the redo stack.
731    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    /// Replaces `range` with `replacement`, re-parses, and leaves the cursor at
768    /// the edit site. Records an undo point first.
769    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    /// Pastes the register at (or after) the cursor. Linewise registers land on
782    /// their own line below (`p`) or above (`P`).
783    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    /// Source content as currently displayed, accounting for unsaved edits.
819    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    /// Source byte range from anchor to cursor. Charwise includes the character
828    /// under the cursor; linewise rounds out to whole lines.
829    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    /// Flashes `range` to acknowledge a yank. The highlight fades on its own
859    /// after [`YANK_FLASH_DURATION`].
860    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    /// The range to flash right now, or `None` once the flash has elapsed.
868    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        // After jumping to the last block (which lands on its first line),
907        // move down to reach the actual last line within that block.
908        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    /// Move the cursor to an arbitrary source byte offset, switching the active
927    /// block (and its text buffer) when the target lies in another block. Unlike
928    /// [`Self::relayout_on_block_change`] this preserves a precise mid-block
929    /// target, so it is the primitive every vim motion moves through.
930    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                // The leading whitespace a change leaves before a block is not
953                // inside any block's range; extend the buffer back over that gap so
954                // inserts land at the cursor. Only bridge whitespace — never block
955                // markers (list bullets, quote glyphs).
956                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        // Deferred initialization: if Edit mode was set before the viewport was
987        // sized (e.g. vim_mode at note open), initialize the text buffer now that
988        // the virtual document has been laid out.
989        //
990        // When re-entering insert mode after an exit_insert (e.g. ESC then `i`
991        // again in vim mode), the virtual_document still reflects the layout
992        // from before commit_text_buffer re-parsed `ast_nodes`, so
993        // `current_block_idx` derived from `cursor.virtual_row` would point at
994        // a stale block. Instead pick the block whose source range contains
995        // the cursor's source offset, so the new buffer starts where the
996        // cursor actually is.
997        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    /// Re-layout while editing so the raw (source) line tracks the cursor.
1068    ///
1069    /// Within a block only the cursor's line is shown raw, so any move that
1070    /// lands on a different line re-runs the layout. Crossing a block boundary
1071    /// additionally switches the text_buffer to the new block.
1072    ///
1073    /// After re-layout the source offset from the old layout may not
1074    /// correspond to the same logical position (e.g. code-block visual
1075    /// vs raw source ranges differ).  We determine whether the cursor
1076    /// entered the block from above or below by comparing block indices
1077    /// and whether the jump crossed more than one block (multi-block
1078    /// jumps like gg/G always go to the entry edge).
1079    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    /// The shift amount can be positive (insertion) or negative (deletion).
1136    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
1143/// Shifts source ranges of top-level AST nodes and any nested children.
1144///
1145/// This function is a helper function intended to shift the source ranges when editing the
1146/// document. After exiting the edit mode, the source ranges are calculated by the parser, so
1147/// we don't have to be precise here.
1148fn 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    /// Editing a block quote shows raw `>` markers line by line: the cursor's
1210    /// line is raw, the rest keep the rendered `┃` marker (read mode renders all
1211    /// lines with `┃`). Ref: issue #486.
1212    #[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        // Cursor on the first line: it is raw, the second stays rendered.
1231        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        // Move to the second line: now it is the raw one.
1241        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    /// A callout (`> [!NOTE]`) renders an icon + label header above its body in
1254    /// read mode, accented in the kind's colour. Ref: #79.
1255    #[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    /// Obsidian's titled/foldable callouts (`[!note]- Title`), which pulldown
1287    /// does not recognise, render with the custom title and drop the raw marker
1288    /// line from the body. Ref: #79.
1289    #[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    /// The full Obsidian type set is supported, including aliases (`summary` is
1316    /// an alias of `abstract`) and types beyond GitHub's five. Ref: #79.
1317    #[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    /// While editing, a callout keeps its per-kind accent colour instead of
1339    /// falling back to the magenta plain-quote bar. Ref: #79.
1340    #[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    /// The block-quote markers are coloured even on the raw cursor line — and
1368    /// every nesting level, not just the first. Ref: #486.
1369    #[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)); // cursor on the quote line
1381
1382        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    /// Lines inside a fenced code block are rendered literally while editing —
1399    /// never decorated as markdown (list bullets, headings, etc.). Ref: #486.
1400    #[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    /// A heading keeps its rendered style and underline while editing; the `#`
1427    /// markers stay visible (and editable). Ref: issue #486.
1428    #[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)); // cursor on the heading
1438
1439        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    /// When the active block's range swallows the blank lines before the next
1453    /// block (a list does this), those blanks must still render as editable
1454    /// lines instead of vanishing. Ref: issue #486.
1455    #[test]
1456    fn test_multiple_blank_lines_after_active_block_render() {
1457        // The list's source range includes the three trailing blank lines.
1458        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    /// Merging a block into the previous one (delete at the buffer start) must
1484    /// keep the merged text visible — the active block renders from a fresh
1485    /// parse of the buffer, not the stale (pre-merge) AST. Ref: issue #486.
1486    #[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        // Down to the blank, then onto "second paragraph", then merge it up.
1498        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    /// An empty list item (`- ` with no text) must still render its marker row
1513    /// instead of vanishing — otherwise splitting an item or adding a line looks
1514    /// like nothing happened. Ref: issue #486.
1515    #[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        // Cursor on "- one"; the empty middle item still occupies a row.
1527        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    /// On a tab-indented line the cursor column must line up with the displayed
1536    /// (tab-expanded) text: a tab is one byte but two columns. Ref: issue #486.
1537    #[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); // onto "\t- b" -> raw "  - b", cursor on 'b'
1548
1549        // 'b' is byte offset 7; the tab (1 byte) renders as 2 columns, so 'b'
1550        // sits at display column 4, not 3.
1551        assert_eq!(state.cursor.source_offset(), 7);
1552        assert_eq!(state.cursor.virtual_column(), 4);
1553
1554        // Stepping left onto the marker stays aligned with the display.
1555        state.cursor_left(2);
1556        assert_eq!(state.cursor.source_offset(), 5); // the '-'
1557        assert_eq!(state.cursor.virtual_column(), 2); // after the 2-col tab
1558    }
1559
1560    /// A tab-indented nested list keeps its indentation when the list is the
1561    /// active (editing) block — raw tabs are expanded to spaces so the terminal
1562    /// doesn't collapse them. Ref: issue #486.
1563    #[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        // Onto the list (cursor on "1. one"); the nested items render decorated.
1574        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    /// A nested list renders cleanly while editing: the cursor's line is raw,
1580    /// deeper items keep their indentation and rendered bullet, and no spurious
1581    /// indent-only lines appear. Ref: issue #486.
1582    #[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        // Cursor on item one: raw marker.
1595        assert_eq!(lines.first().map(String::as_str), Some("- item one"));
1596        // Nested items: indentation preserved, rendered bullet.
1597        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        // No stray indent-only line (the bug this redesign fixes).
1606        assert!(
1607            !lines
1608                .iter()
1609                .any(|line| !line.is_empty() && line.trim().is_empty()),
1610            "spurious whitespace line: {lines:?}",
1611        );
1612    }
1613
1614    /// Reaching a list item via `jump_to_offset` (a vim motion) must not corrupt
1615    /// the markers of the other items — the gap-bridging in `jump_to_offset` only
1616    /// applies to whitespace, never a list bullet.
1617    #[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    /// Deleting the blank line before a list item must pull the whole item up
1644    /// intact — its marker and text stay on one row. Ref: issue #486.
1645    #[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        // Onto the blank line between the items, then delete it.
1657        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    /// A loose list (blank line between items) must render a single blank line
1670    /// between items even when the cursor is on an item rendered raw — the raw
1671    /// item's trailing blank and the list's empty-line preservation must not
1672    /// stack. The blank must stay editable (the cursor can land on it). Ref:
1673    /// issue #486.
1674    #[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        // Cursor lands on item "a", which renders raw.
1686        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        // The blank between the items is reachable, so it can be edited away.
1696        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    /// Pressing Enter at the very start of a list item must insert a blank line
1708    /// and push the item down, not drop the item's text. Ref: issue #486.
1709    #[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        // Cursor lands on the item, now on the second line, at its start.
1728        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        // A heading below the viewport, jumped to from the outline, should land
1766        // on the first visible line so its content stays visible. Ref: issue #615.
1767        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        // Code-block lines are not wrapped, so a long one overflows the viewport
1794        // and the cursor must pan it horizontally to stay visible.
1795        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        // Land on the code line and walk to its end.
1804        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        // The active code block has a short line and a long one. Panning right to
1837        // follow the long line must keep the short line's background reaching the
1838        // viewport's right edge (left + width), not stop at its own content.
1839        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        // Step onto the long line and pan into it.
1848        state.cursor_down(2);
1849        state.cursor_right(80);
1850        state.update_layout(); // editor.rs re-lays out against the scroll each frame.
1851
1852        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        // Editing a long line in one block pans the whole viewport. The fills of
1866        // the *other* visible blocks must extend too, even though those blocks
1867        // are not the one being edited. Here a second, non-active code block.
1868        let long = "x".repeat(100);
1869        let content = format!("```\n{long}\n```\n\n```\nbbb\n```\n");
1870        // The second code block opens at the fence after the blank line.
1871        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        // Pan into the long line of the first (active) code block.
1880        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}