Skip to main content

basalt_tui/note_editor/
mod.rs

1pub mod ast;
2mod cursor;
3pub mod editor;
4mod motion;
5pub mod parser;
6mod render;
7mod rich_text;
8pub mod state;
9mod text_buffer;
10mod text_wrap;
11mod viewport;
12mod virtual_document;
13
14// `Direction` is part of the public `Message::FindChar` API, so re-export it
15// for callers (command dispatch) that construct find messages.
16pub use motion::Direction;
17
18use std::time::Duration;
19
20use ratatui::{
21    crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
22    layout::Size,
23};
24
25use crate::{
26    app::{calc_scroll_amount, ActivePane, Message as AppMessage, ScrollAmount},
27    explorer,
28    note_editor::state::{EditMode, FindKind, NoteEditorState, Operator, SelectionMode, View},
29    outline, toast,
30};
31
32#[derive(Clone, Debug, PartialEq)]
33pub enum Message {
34    Save,
35    SwitchPaneNext,
36    SwitchPanePrevious,
37    ToggleExplorer,
38    ToggleOutline,
39    ToggleView,
40    EditView,
41    ReadView,
42    Exit,
43    KeyEvent(KeyEvent),
44    CursorUp,
45    CursorLeft,
46    CursorRight,
47    CursorWordForward,
48    CursorWordBackward,
49    CursorDown,
50    ScrollUp(ScrollAmount),
51    ScrollDown(ScrollAmount),
52    ScrollToTop,
53    ScrollToBottom,
54    JumpToBlock(usize),
55    Delete,
56    InsertMode,
57    VisualMode,
58    VisualLineMode,
59    CursorLineStart,
60    CursorLineEnd,
61    CursorFirstNonblank,
62    CursorWordEnd,
63    CursorWordForwardBig,
64    CursorWordBackwardBig,
65    CursorWordEndBig,
66    ParagraphForward,
67    ParagraphBackward,
68    MatchingPair,
69    CursorDocStart,
70    CursorDocEnd,
71    FindChar {
72        direction: motion::Direction,
73        till: bool,
74    },
75    RepeatFind {
76        reverse: bool,
77    },
78    CountDigit(u8),
79    FindTarget(char),
80    TextObjectTarget(char),
81    ReplaceChar,
82    ReplaceTarget(char),
83    Append,
84    Operator(Operator),
85    DeleteUnderCursor,
86    DeleteToLineEnd,
87    ChangeToLineEnd,
88    SubstituteChar,
89    PasteAfter,
90    PasteBefore,
91    Undo,
92    Redo,
93}
94
95fn offset(state: &NoteEditorState) -> usize {
96    state.cursor.source_offset()
97}
98
99fn content_update<'a>(state: &NoteEditorState) -> AppMessage<'a> {
100    AppMessage::UpdateSelectedNoteContent((
101        state.content.to_string(),
102        Some(state.ast_nodes.clone()),
103    ))
104}
105
106fn select_at_cursor<'a>(state: &NoteEditorState) -> Option<AppMessage<'a>> {
107    Some(AppMessage::Outline(outline::Message::SelectAt(
108        state.current_block_idx(),
109    )))
110}
111
112fn save<'a>(state: &mut NoteEditorState) -> Option<AppMessage<'a>> {
113    let modified = state.modified();
114    match state.save_to_file() {
115        Ok(_) if modified => Some(AppMessage::Batch(vec![
116            AppMessage::UpdateSelectedNoteContent((state.content.to_string(), None)),
117            AppMessage::Toast(toast::Message::Create(toast::Toast::success(
118                "File saved",
119                Duration::from_secs(2),
120            ))),
121        ])),
122        Err(_) => Some(AppMessage::Toast(toast::Message::Create(
123            toast::Toast::error("Failed to save file", Duration::from_secs(2)),
124        ))),
125        _ => None,
126    }
127}
128
129fn shared_message<'a>(state: &mut NoteEditorState, message: Message) -> Option<AppMessage<'a>> {
130    match message {
131        Message::ToggleExplorer => Some(AppMessage::Explorer(explorer::Message::Toggle)),
132        Message::ToggleOutline => Some(AppMessage::Outline(outline::Message::Toggle)),
133        Message::SwitchPaneNext => {
134            state.set_active(false);
135            Some(AppMessage::SetActivePane(ActivePane::Outline))
136        }
137        Message::SwitchPanePrevious => {
138            state.set_active(false);
139            Some(AppMessage::SetActivePane(ActivePane::Explorer))
140        }
141        Message::Save => save(state),
142        _ => None,
143    }
144}
145
146fn run_motion<'a>(
147    state: &mut NoteEditorState,
148    count: usize,
149    inclusive: bool,
150    linewise: bool,
151    motion: impl Fn(&str, usize) -> usize,
152) -> Option<AppMessage<'a>> {
153    let target = (0..count).fold(offset(state), |from, _| motion(&state.content, from));
154    match state.take_operator() {
155        Some(operator) => operate(state, operator, target, inclusive, linewise),
156        None => motion_to(state, target),
157    }
158}
159
160fn apply_find<'a>(
161    state: &mut NoteEditorState,
162    target: char,
163    kind: FindKind,
164    count: usize,
165) -> Option<AppMessage<'a>> {
166    let forward = kind.direction == motion::Direction::Forward;
167    run_motion(state, count, forward, false, move |content, from| {
168        motion::find_char(content, from, target, kind.direction, kind.till).unwrap_or(from)
169    })
170}
171
172fn operate<'a>(
173    state: &mut NoteEditorState,
174    operator: Operator,
175    target: usize,
176    inclusive: bool,
177    linewise: bool,
178) -> Option<AppMessage<'a>> {
179    let cursor = offset(state);
180    let (lo, hi) = (cursor.min(target), cursor.max(target));
181    let range = if linewise {
182        let start = state.content[..lo].rfind('\n').map_or(0, |i| i + 1);
183        let end = state.content[hi..]
184            .find('\n')
185            .map_or(state.content.len(), |i| hi + i + 1);
186        start..end
187    } else {
188        let end = if inclusive {
189            hi + state.content[hi..].chars().next().map_or(0, char::len_utf8)
190        } else {
191            hi
192        };
193        lo..end
194    };
195    match operator {
196        Operator::Yank => yank(state, range, linewise),
197        Operator::Delete => delete(state, range, linewise),
198        Operator::Change => change(state, range, linewise),
199    }
200}
201
202fn yank<'a>(
203    state: &mut NoteEditorState,
204    range: core::ops::Range<usize>,
205    linewise: bool,
206) -> Option<AppMessage<'a>> {
207    let text = state.content.get(range.clone())?.to_string();
208    if text.is_empty() {
209        return None;
210    }
211    state.set_register(text.clone(), linewise);
212    state.flash_yank(range.clone());
213    state.jump_to_offset(range.start);
214    Some(AppMessage::CopyToClipboard(text))
215}
216
217fn delete<'a>(
218    state: &mut NoteEditorState,
219    range: core::ops::Range<usize>,
220    linewise: bool,
221) -> Option<AppMessage<'a>> {
222    let text = state.content.get(range.clone())?.to_string();
223    if text.is_empty() {
224        return None;
225    }
226    state.set_register(text, linewise);
227    state.splice(range, "");
228    Some(content_update(state))
229}
230
231fn change<'a>(
232    state: &mut NoteEditorState,
233    range: core::ops::Range<usize>,
234    linewise: bool,
235) -> Option<AppMessage<'a>> {
236    let text = state.content.get(range.clone())?.to_string();
237    state.set_register(text, linewise);
238    state.splice(range, "");
239    state.set_insert_mode(true);
240    Some(content_update(state))
241}
242
243fn apply_to_selection<'a>(
244    state: &mut NoteEditorState,
245    operator: Operator,
246) -> Option<AppMessage<'a>> {
247    let linewise = matches!(
248        state.selection().map(|selection| selection.mode),
249        Some(SelectionMode::Line)
250    );
251    let range = state.selection_range()?;
252    state.clear_selection();
253    match operator {
254        Operator::Yank => yank(state, range, linewise),
255        Operator::Delete => delete(state, range, linewise),
256        Operator::Change => change(state, range, linewise),
257    }
258}
259
260fn operate_lines<'a>(
261    state: &mut NoteEditorState,
262    operator: Operator,
263    count: usize,
264) -> Option<AppMessage<'a>> {
265    let cursor = offset(state);
266    let start = state.content[..cursor].rfind('\n').map_or(0, |i| i + 1);
267    let end = (0..count).fold(start, |line, _| {
268        state.content[line..]
269            .find('\n')
270            .map_or(state.content.len(), |i| line + i + 1)
271    });
272    match operator {
273        Operator::Yank => yank(state, start..end, true),
274        Operator::Delete => delete(state, start..end, true),
275        Operator::Change => change(state, start..end, true),
276    }
277}
278
279fn motion_to<'a>(state: &mut NoteEditorState, offset: usize) -> Option<AppMessage<'a>> {
280    state.jump_to_offset(offset);
281    select_at_cursor(state)
282}
283
284// FIXME: Add resize message to handle resize related updates like cursor positioning
285pub fn update<'a>(
286    message: Message,
287    screen_size: Size,
288    state: &mut NoteEditorState,
289) -> Option<AppMessage<'a>> {
290    let vim_mode = state.vim_mode();
291
292    if !matches!(message, Message::FindTarget(_)) {
293        state.clear_pending_find();
294    }
295    if !matches!(message, Message::TextObjectTarget(_)) {
296        state.clear_pending_text_object();
297    }
298    if !matches!(message, Message::ReplaceTarget(_)) {
299        state.clear_pending_replace();
300    }
301
302    match message {
303        Message::CursorLeft => {
304            let count = state.take_count().unwrap_or(1);
305            if state.pending_operator().is_some() {
306                return run_motion(state, count, false, false, |content, from| {
307                    motion::nth_char_left(content, from, 1)
308                });
309            }
310            state.cursor_left(count);
311        }
312        Message::CursorRight => {
313            let count = state.take_count().unwrap_or(1);
314            if state.pending_operator().is_some() {
315                return run_motion(state, count, false, false, |content, from| {
316                    motion::nth_char_right(content, from, 1)
317                });
318            }
319            state.cursor_right(count);
320        }
321        Message::JumpToBlock(idx) => state.cursor_jump(idx),
322        Message::CursorUp => {
323            let count = state.take_count().unwrap_or(1);
324            if state.pending_operator().is_some() {
325                return run_motion(state, 1, false, true, move |content, from| {
326                    motion::line_up(content, from, count)
327                });
328            }
329            state.cursor_up(count);
330            return select_at_cursor(state);
331        }
332        Message::CursorDown => {
333            let count = state.take_count().unwrap_or(1);
334            if state.pending_operator().is_some() {
335                return run_motion(state, 1, false, true, move |content, from| {
336                    motion::line_down(content, from, count)
337                });
338            }
339            state.cursor_down(count);
340            return select_at_cursor(state);
341        }
342        Message::ScrollUp(scroll_amount) => {
343            state.cursor_up(calc_scroll_amount(
344                &scroll_amount,
345                screen_size.height.into(),
346            ));
347            return select_at_cursor(state);
348        }
349        Message::ScrollDown(scroll_amount) => {
350            state.cursor_down(calc_scroll_amount(
351                &scroll_amount,
352                screen_size.height.into(),
353            ));
354            return select_at_cursor(state);
355        }
356        Message::ScrollToTop => {
357            state.cursor_up(usize::MAX);
358            return select_at_cursor(state);
359        }
360        Message::ScrollToBottom => {
361            state.cursor_to_end();
362            return select_at_cursor(state);
363        }
364        _ => {}
365    };
366
367    match state.view {
368        View::Edit(..) if state.insert_mode() => match message {
369            Message::CursorWordForward => {
370                let target = motion::word_forward(&state.content, offset(state), false);
371                state.jump_to_offset(target);
372            }
373            Message::CursorWordBackward => {
374                let target = motion::word_backward(&state.content, offset(state), false);
375                state.jump_to_offset(target);
376            }
377            Message::ToggleView | Message::ReadView => {
378                state.set_insert_mode(false);
379                state.exit_insert();
380                state.set_view(View::Read);
381                return Some(content_update(state));
382            }
383            Message::KeyEvent(key) => {
384                match key.code {
385                    KeyCode::Char(c) => {
386                        state.insert_char(c);
387                    }
388                    KeyCode::Enter => {
389                        state.insert_char('\n');
390                    }
391                    _ => {}
392                }
393
394                return Some(AppMessage::UpdateSelectedNoteContent((
395                    state.content.to_string(),
396                    None,
397                )));
398            }
399            Message::Delete => {
400                state.delete_char();
401            }
402            Message::Exit => {
403                state.set_insert_mode(false);
404                state.exit_insert();
405                if !vim_mode {
406                    state.set_view(View::Read);
407                }
408                return Some(content_update(state));
409            }
410            _ => {}
411        },
412        View::Edit(..) => match message {
413            // Normal mode (vim): navigation and read-mode equivalents
414            Message::CursorWordForward => {
415                let count = state.take_count().unwrap_or(1);
416                // vim quirk: `cw` acts like `ce`, changing to the word end
417                // instead of swallowing the trailing whitespace.
418                if state.pending_operator() == Some(Operator::Change) {
419                    return run_motion(state, count, true, false, |content, from| {
420                        motion::word_end(content, from, false)
421                    });
422                }
423                return run_motion(state, count, false, false, |content, from| {
424                    motion::word_forward(content, from, false)
425                });
426            }
427            Message::CursorWordBackward => {
428                let count = state.take_count().unwrap_or(1);
429                return run_motion(state, count, false, false, |content, from| {
430                    motion::word_backward(content, from, false)
431                });
432            }
433            Message::CursorWordEnd => {
434                let count = state.take_count().unwrap_or(1);
435                return run_motion(state, count, true, false, |content, from| {
436                    motion::word_end(content, from, false)
437                });
438            }
439            Message::CursorWordForwardBig => {
440                let count = state.take_count().unwrap_or(1);
441                if state.pending_operator() == Some(Operator::Change) {
442                    return run_motion(state, count, true, false, |content, from| {
443                        motion::word_end(content, from, true)
444                    });
445                }
446                return run_motion(state, count, false, false, |content, from| {
447                    motion::word_forward(content, from, true)
448                });
449            }
450            Message::CursorWordBackwardBig => {
451                let count = state.take_count().unwrap_or(1);
452                return run_motion(state, count, false, false, |content, from| {
453                    motion::word_backward(content, from, true)
454                });
455            }
456            Message::CursorWordEndBig => {
457                let count = state.take_count().unwrap_or(1);
458                return run_motion(state, count, true, false, |content, from| {
459                    motion::word_end(content, from, true)
460                });
461            }
462            Message::CursorLineStart => {
463                state.reset_count();
464                return run_motion(state, 1, false, false, motion::line_start);
465            }
466            Message::CursorLineEnd => {
467                state.reset_count();
468                return run_motion(state, 1, true, false, motion::line_end);
469            }
470            Message::CursorFirstNonblank => {
471                state.reset_count();
472                return run_motion(state, 1, false, false, motion::first_nonblank);
473            }
474            Message::ParagraphForward => {
475                let count = state.take_count().unwrap_or(1);
476                return run_motion(state, count, false, false, motion::paragraph_forward);
477            }
478            Message::ParagraphBackward => {
479                let count = state.take_count().unwrap_or(1);
480                return run_motion(state, count, false, false, motion::paragraph_backward);
481            }
482            Message::MatchingPair => {
483                state.reset_count();
484                let target = motion::matching_pair(&state.content, offset(state));
485                return match (state.take_operator(), target) {
486                    (Some(operator), Some(target)) => operate(state, operator, target, true, false),
487                    (None, Some(target)) => motion_to(state, target),
488                    _ => None,
489                };
490            }
491            Message::CursorDocStart => {
492                let count = state.take_count();
493                return run_motion(state, 1, false, true, move |content, _| {
494                    count.map_or_else(
495                        || motion::doc_start(content),
496                        |line| motion::goto_line(content, line),
497                    )
498                });
499            }
500            Message::CursorDocEnd => {
501                let count = state.take_count();
502                return run_motion(state, 1, false, true, move |content, _| {
503                    count.map_or_else(
504                        || motion::doc_end(content),
505                        |line| motion::goto_line(content, line),
506                    )
507                });
508            }
509            Message::Operator(operator) => {
510                if state.is_selecting() {
511                    return apply_to_selection(state, operator);
512                }
513                match state.pending_operator() {
514                    Some(pending) if pending == operator => {
515                        let count = state.take_count().unwrap_or(1);
516                        state.clear_operator();
517                        return operate_lines(state, operator, count);
518                    }
519                    _ => state.set_operator(operator),
520                }
521            }
522            Message::DeleteUnderCursor => {
523                let count = state.take_count().unwrap_or(1);
524                state.clear_operator();
525                let cursor = offset(state);
526                let end = motion::nth_char_right(&state.content, cursor, count);
527                return delete(state, cursor..end, false);
528            }
529            Message::DeleteToLineEnd => {
530                state.clear_operator();
531                let cursor = offset(state);
532                let end = motion::line_end_exclusive(&state.content, cursor);
533                return delete(state, cursor..end, false);
534            }
535            Message::ChangeToLineEnd => {
536                state.clear_operator();
537                let cursor = offset(state);
538                let end = motion::line_end_exclusive(&state.content, cursor);
539                return change(state, cursor..end, false);
540            }
541            Message::SubstituteChar => {
542                let count = state.take_count().unwrap_or(1);
543                state.clear_operator();
544                let cursor = offset(state);
545                let end = motion::nth_char_right(&state.content, cursor, count);
546                return change(state, cursor..end, false);
547            }
548            Message::PasteAfter => {
549                state.clear_operator();
550                state.paste(true);
551                return Some(content_update(state));
552            }
553            Message::PasteBefore => {
554                state.clear_operator();
555                state.paste(false);
556                return Some(content_update(state));
557            }
558            Message::Undo => {
559                state.clear_operator();
560                if state.undo() {
561                    return Some(content_update(state));
562                }
563            }
564            Message::Redo => {
565                state.clear_operator();
566                if state.redo() {
567                    return Some(content_update(state));
568                }
569            }
570            Message::FindChar { direction, till } => state.arm_find(direction, till),
571            Message::FindTarget(character) => {
572                let count = state.take_count().unwrap_or(1);
573                if let Some(kind) = state.take_pending_find() {
574                    state.remember_find(character, kind);
575                    return apply_find(state, character, kind, count);
576                }
577            }
578            Message::RepeatFind { reverse } => {
579                let count = state.take_count().unwrap_or(1);
580                if let Some((target, kind)) = state.last_find() {
581                    let kind = if reverse {
582                        FindKind {
583                            direction: kind.direction.flip(),
584                            till: kind.till,
585                        }
586                    } else {
587                        kind
588                    };
589                    return apply_find(state, target, kind, count);
590                }
591            }
592            Message::CountDigit(digit) => state.push_count_digit(digit),
593            Message::VisualMode => {
594                state.clear_operator();
595                state.toggle_selection(SelectionMode::Char);
596            }
597            Message::VisualLineMode => {
598                state.clear_operator();
599                state.toggle_selection(SelectionMode::Line);
600            }
601            Message::Exit => {
602                state.reset_count();
603                state.clear_operator();
604                state.clear_selection();
605            }
606            Message::InsertMode if state.pending_operator().is_some() => {
607                state.arm_text_object(motion::TextObjectKind::Inner);
608            }
609            Message::InsertMode | Message::EditView => {
610                state.reset_count();
611                state.clear_operator();
612                state.clear_selection();
613                state.mark_undo_point();
614                state.set_insert_mode(true);
615            }
616            Message::Append => {
617                if state.pending_operator().is_some() {
618                    state.arm_text_object(motion::TextObjectKind::Around);
619                } else {
620                    state.reset_count();
621                    state.clear_selection();
622                    state.mark_undo_point();
623                    let target = motion::nth_char_right(&state.content, offset(state), 1);
624                    state.jump_to_offset(target);
625                    state.set_insert_mode(true);
626                }
627            }
628            Message::TextObjectTarget(object) => {
629                let kind = state.take_text_object();
630                let operator = state.take_operator();
631                if let (Some(kind), Some(operator)) = (kind, operator) {
632                    if let Some(range) =
633                        motion::text_object(&state.content, offset(state), object, kind)
634                    {
635                        return match operator {
636                            Operator::Yank => yank(state, range, false),
637                            Operator::Delete => delete(state, range, false),
638                            Operator::Change => change(state, range, false),
639                        };
640                    }
641                }
642            }
643            Message::ReplaceChar => {
644                state.clear_operator();
645                state.arm_replace();
646            }
647            Message::ReplaceTarget(character) => {
648                state.clear_pending_replace();
649                let count = state.take_count().unwrap_or(1);
650                let cursor = offset(state);
651                let end = motion::nth_char_right(&state.content, cursor, count);
652                let replaced = state.content[cursor..end].chars().count();
653                if replaced > 0 {
654                    let replacement = character.to_string().repeat(replaced);
655                    let landing = (cursor + replacement.len()).saturating_sub(character.len_utf8());
656                    state.splice(cursor..end, &replacement);
657                    state.jump_to_offset(landing);
658                    return Some(content_update(state));
659                }
660            }
661            Message::ToggleView | Message::ReadView => {
662                state.clear_selection();
663                state.exit_insert();
664                state.set_view(View::Read);
665                return Some(content_update(state));
666            }
667            message => return shared_message(state, message),
668        },
669        View::Read => match message {
670            Message::ToggleView if state.editor_enabled() => {
671                state.set_view(View::Edit(EditMode::Source))
672            }
673            Message::EditView | Message::InsertMode if state.editor_enabled() => {
674                state.set_view(View::Edit(EditMode::Source));
675                state.set_insert_mode(true);
676            }
677            Message::ReadView => state.set_view(View::Read),
678            message => return shared_message(state, message),
679        },
680    }
681
682    None
683}
684
685pub fn handle_editing_event(key: KeyEvent) -> Option<Message> {
686    match key.code {
687        KeyCode::Up => Some(Message::CursorUp),
688        KeyCode::Down => Some(Message::CursorDown),
689        KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::ALT) => {
690            Some(Message::CursorWordForward)
691        }
692        KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::ALT) => {
693            Some(Message::CursorWordBackward)
694        }
695        KeyCode::Left => Some(Message::CursorLeft),
696        KeyCode::Right => Some(Message::CursorRight),
697        KeyCode::Esc => Some(Message::Exit),
698        KeyCode::Backspace => Some(Message::Delete),
699        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
700            Some(Message::ToggleView)
701        }
702        _ => Some(Message::KeyEvent(key)),
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use std::path::Path;
709
710    use ratatui::layout::Size;
711
712    use super::*;
713    use crate::{config::Symbols, note_editor::state::EditMode};
714
715    fn vim_edit_state(content: &str) -> NoteEditorState<'static> {
716        let mut state =
717            NoteEditorState::new(content, "test", Path::new("test.md"), &Symbols::unicode());
718        state.set_vim_mode(true);
719        state.resize_viewport(Size::new(40, 10));
720        state.set_view(View::Edit(EditMode::Source));
721        state
722    }
723
724    #[test]
725    fn test_yank_emits_copy_to_clipboard() {
726        let mut state = vim_edit_state("hello world\n");
727        let size = Size::new(40, 10);
728
729        update(Message::VisualMode, size, &mut state);
730        update(Message::CursorRight, size, &mut state);
731        update(Message::CursorRight, size, &mut state);
732        update(Message::CursorRight, size, &mut state);
733        update(Message::CursorRight, size, &mut state);
734
735        let message = update(Message::Operator(Operator::Yank), size, &mut state);
736
737        assert_eq!(
738            message,
739            Some(AppMessage::CopyToClipboard("hello".to_string()))
740        );
741        assert!(!state.is_selecting(), "yank should clear the selection");
742        assert_eq!(
743            state.yank_flash_range(),
744            Some(0..5),
745            "yank should flash the copied range"
746        );
747    }
748
749    #[test]
750    fn test_yank_without_selection_arms_operator() {
751        let mut state = vim_edit_state("hello world\n");
752        // y with no selection arms the yank operator rather than copying.
753        assert_eq!(
754            update(
755                Message::Operator(Operator::Yank),
756                Size::new(40, 10),
757                &mut state
758            ),
759            None
760        );
761        assert_eq!(state.pending_operator(), Some(Operator::Yank));
762    }
763
764    #[test]
765    fn test_line_and_word_motions() {
766        let mut state = vim_edit_state("foo bar baz\n");
767        let size = Size::new(40, 10);
768
769        update(Message::CursorLineEnd, size, &mut state);
770        assert_eq!(state.cursor.source_offset(), 10, "$ lands on the last char");
771
772        update(Message::CursorLineStart, size, &mut state);
773        assert_eq!(state.cursor.source_offset(), 0, "0 lands on the first col");
774
775        update(Message::CursorWordForward, size, &mut state);
776        assert_eq!(state.cursor.source_offset(), 4, "w lands on 'bar'");
777
778        update(Message::CursorWordEnd, size, &mut state);
779        assert_eq!(state.cursor.source_offset(), 6, "e lands on end of 'bar'");
780
781        update(Message::CursorWordBackward, size, &mut state);
782        assert_eq!(state.cursor.source_offset(), 4, "b lands on start of 'bar'");
783    }
784
785    #[test]
786    fn insert_mode_word_motion_uses_engine() {
787        // In insert mode (alt+f / alt+b) word motion now shares the vim engine,
788        // so it stops at punctuation instead of splitting only on spaces.
789        let mut state = vim_edit_state("foo.bar baz\n");
790        state.set_insert_mode(true);
791        let size = Size::new(40, 10);
792
793        update(Message::CursorWordForward, size, &mut state);
794        assert_eq!(
795            state.cursor.source_offset(),
796            3,
797            "alt+f stops at the '.' punctuation boundary"
798        );
799
800        update(Message::CursorWordBackward, size, &mut state);
801        assert_eq!(
802            state.cursor.source_offset(),
803            0,
804            "alt+b returns to the word start"
805        );
806    }
807
808    #[test]
809    fn test_motion_crosses_block_boundary() {
810        let mut state = vim_edit_state("# Title\n\nsecond paragraph\n");
811        let size = Size::new(40, 12);
812
813        // Jump straight to the last block, then to its end.
814        update(Message::CursorDocEnd, size, &mut state);
815        let content = state.content.clone();
816        let second = content.find("second").unwrap();
817        assert!(
818            state.cursor.source_offset() >= second,
819            "G reaches the second block (offset {} >= {second})",
820            state.cursor.source_offset(),
821        );
822        assert!(state.current_block_idx() >= 1, "cursor is in a later block");
823
824        update(Message::CursorDocStart, size, &mut state);
825        assert_eq!(
826            state.cursor.source_offset(),
827            0,
828            "gg returns to the first block"
829        );
830        assert_eq!(state.current_block_idx(), 0);
831    }
832
833    #[test]
834    fn test_find_char_and_repeat() {
835        // a b c x d e f x
836        let mut state = vim_edit_state("abcxdefx\n");
837        let size = Size::new(40, 10);
838
839        update(
840            Message::FindChar {
841                direction: motion::Direction::Forward,
842                till: false,
843            },
844            size,
845            &mut state,
846        );
847        update(Message::FindTarget('x'), size, &mut state);
848        assert_eq!(state.cursor.source_offset(), 3, "f x -> first x");
849
850        update(Message::RepeatFind { reverse: false }, size, &mut state);
851        assert_eq!(state.cursor.source_offset(), 7, "; -> next x");
852
853        update(Message::RepeatFind { reverse: true }, size, &mut state);
854        assert_eq!(state.cursor.source_offset(), 3, ", -> back to first x");
855    }
856
857    #[test]
858    fn test_till_stops_before_target() {
859        let mut state = vim_edit_state("abcxdef\n");
860        let size = Size::new(40, 10);
861        update(
862            Message::FindChar {
863                direction: motion::Direction::Forward,
864                till: true,
865            },
866            size,
867            &mut state,
868        );
869        update(Message::FindTarget('x'), size, &mut state);
870        assert_eq!(state.cursor.source_offset(), 2, "t x -> just before x");
871    }
872
873    #[test]
874    fn test_count_repeats_word_motion() {
875        // one two three four
876        let mut state = vim_edit_state("one two three four\n");
877        let size = Size::new(60, 10);
878
879        update(Message::CountDigit(3), size, &mut state);
880        update(Message::CursorWordForward, size, &mut state);
881        assert_eq!(state.cursor.source_offset(), 14, "3w -> start of 'four'");
882    }
883
884    #[test]
885    fn test_multi_digit_count() {
886        let mut state = vim_edit_state("abcdefghijklmno\n");
887        let size = Size::new(40, 10);
888
889        update(Message::CountDigit(1), size, &mut state);
890        update(Message::CountDigit(2), size, &mut state);
891        update(Message::CursorRight, size, &mut state);
892        assert_eq!(state.cursor.source_offset(), 12, "12l -> offset 12");
893    }
894
895    fn delete(state: &mut NoteEditorState) {
896        update(
897            Message::Operator(Operator::Delete),
898            Size::new(60, 12),
899            state,
900        );
901    }
902
903    #[test]
904    fn test_delete_word() {
905        let mut state = vim_edit_state("foo bar baz\n");
906        delete(&mut state);
907        update(Message::CursorWordForward, Size::new(60, 12), &mut state);
908        assert_eq!(
909            state.content, "bar baz\n",
910            "dw removes the first word and space"
911        );
912    }
913
914    #[test]
915    fn test_delete_line_doubled_operator() {
916        let mut state = vim_edit_state("line one\nline two\nline three\n");
917        delete(&mut state);
918        delete(&mut state); // dd
919        assert_eq!(state.content, "line two\nline three\n");
920    }
921
922    #[test]
923    fn test_change_word_is_change_to_end() {
924        let mut state = vim_edit_state("foo bar\n");
925        let size = Size::new(60, 12);
926        update(Message::Operator(Operator::Change), size, &mut state);
927        update(Message::CursorWordForward, size, &mut state);
928        assert_eq!(
929            state.content, " bar\n",
930            "cw changes to word end, keeping the space"
931        );
932        assert!(state.insert_mode(), "change enters insert mode");
933    }
934
935    #[test]
936    fn test_delete_char_under_cursor() {
937        let mut state = vim_edit_state("abc\n");
938        update(Message::DeleteUnderCursor, Size::new(40, 10), &mut state);
939        assert_eq!(state.content, "bc\n");
940        assert_eq!(state.register().text, "a");
941    }
942
943    #[test]
944    fn test_yank_line_and_paste() {
945        let mut state = vim_edit_state("one\ntwo\n");
946        let size = Size::new(40, 10);
947        update(Message::Operator(Operator::Yank), size, &mut state);
948        update(Message::Operator(Operator::Yank), size, &mut state); // yy
949        assert!(state.register().linewise);
950        update(Message::PasteAfter, size, &mut state);
951        assert_eq!(state.content, "one\none\ntwo\n", "p pastes the line below");
952    }
953
954    #[test]
955    fn test_undo_redo() {
956        let mut state = vim_edit_state("hello\n");
957        let size = Size::new(40, 10);
958        delete(&mut state);
959        delete(&mut state); // dd -> empty
960        assert_eq!(state.content, "");
961        update(Message::Undo, size, &mut state);
962        assert_eq!(state.content, "hello\n", "u restores the deleted line");
963        update(Message::Redo, size, &mut state);
964        assert_eq!(state.content, "", "ctrl+r reapplies the delete");
965    }
966
967    #[test]
968    fn test_change_inner_quotes() {
969        let mut state = vim_edit_state("say \"hello\" now\n");
970        let size = Size::new(40, 10);
971        update(Message::Operator(Operator::Change), size, &mut state); // c
972        update(Message::InsertMode, size, &mut state); // i (inner, operator pending)
973        update(Message::TextObjectTarget('"'), size, &mut state); // "
974        assert_eq!(
975            state.content, "say \"\" now\n",
976            "ci\" clears inside the quotes"
977        );
978        assert!(state.insert_mode(), "change enters insert mode");
979    }
980
981    #[test]
982    fn test_change_word_in_second_block() {
983        let mut state = vim_edit_state("# Title\n\nThe quick brown fox\n");
984        let size = Size::new(50, 12);
985        let t = state.content.find("The").unwrap();
986        state.jump_to_offset(t);
987        update(Message::Operator(Operator::Change), size, &mut state);
988        update(Message::CursorWordForward, size, &mut state);
989        for character in "swift".chars() {
990            update(
991                Message::KeyEvent(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)),
992                size,
993                &mut state,
994            );
995        }
996        update(Message::Exit, size, &mut state);
997        assert_eq!(state.content, "# Title\n\nswift quick brown fox\n");
998    }
999
1000    #[test]
1001    fn test_replace_char() {
1002        let mut state = vim_edit_state("cat\n");
1003        let size = Size::new(40, 10);
1004        update(Message::ReplaceChar, size, &mut state);
1005        update(Message::ReplaceTarget('b'), size, &mut state);
1006        assert_eq!(state.content, "bat\n");
1007        assert!(!state.insert_mode(), "r stays in normal mode");
1008        assert_eq!(
1009            state.cursor.source_offset(),
1010            0,
1011            "cursor stays on replaced char"
1012        );
1013        assert!(
1014            !state.awaiting_replace(),
1015            "replace is one-shot: the next key is not swallowed"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_replace_char_with_count() {
1021        let mut state = vim_edit_state("cat\n");
1022        let size = Size::new(40, 10);
1023        update(Message::CountDigit(3), size, &mut state);
1024        update(Message::ReplaceChar, size, &mut state);
1025        update(Message::ReplaceTarget('x'), size, &mut state);
1026        assert_eq!(state.content, "xxx\n", "3rx replaces three chars");
1027    }
1028
1029    #[test]
1030    fn test_delete_around_parens() {
1031        let mut state = vim_edit_state("call(a, b)\n");
1032        let size = Size::new(40, 10);
1033        // Move onto the '(' first — bracket objects need the cursor inside the pair.
1034        update(
1035            Message::FindChar {
1036                direction: motion::Direction::Forward,
1037                till: false,
1038            },
1039            size,
1040            &mut state,
1041        );
1042        update(Message::FindTarget('('), size, &mut state);
1043        update(Message::Operator(Operator::Delete), size, &mut state); // d
1044        update(Message::Append, size, &mut state); // a (around, operator pending)
1045        update(Message::TextObjectTarget('('), size, &mut state);
1046        assert_eq!(state.content, "call\n", "da( removes the whole (...)");
1047    }
1048
1049    #[test]
1050    fn test_word_forward_big_skips_punctuation() {
1051        let mut state = vim_edit_state("foo.bar baz\n");
1052        update(Message::CursorWordForwardBig, Size::new(60, 10), &mut state);
1053        assert_eq!(
1054            state.cursor.source_offset(),
1055            8,
1056            "W treats foo.bar as one WORD"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_visual_delete_selection() {
1062        let mut state = vim_edit_state("hello world\n");
1063        let size = Size::new(40, 10);
1064        update(Message::VisualMode, size, &mut state);
1065        for _ in 0..4 {
1066            update(Message::CursorRight, size, &mut state);
1067        }
1068        update(Message::Operator(Operator::Delete), size, &mut state);
1069        assert_eq!(
1070            state.content, " world\n",
1071            "v + motion + d deletes the selection"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_delete_to_line_end() {
1077        let mut state = vim_edit_state("abcdef\n");
1078        state.jump_to_offset(3);
1079        update(Message::DeleteToLineEnd, Size::new(40, 10), &mut state); // D
1080        assert_eq!(state.content, "abc\n");
1081        assert_eq!(state.register().text, "def");
1082    }
1083
1084    #[test]
1085    fn test_change_to_line_end() {
1086        let mut state = vim_edit_state("abcdef\n");
1087        state.jump_to_offset(3);
1088        update(Message::ChangeToLineEnd, Size::new(40, 10), &mut state); // C
1089        assert_eq!(state.content, "abc\n");
1090        assert!(state.insert_mode(), "C enters insert mode");
1091    }
1092
1093    #[test]
1094    fn test_substitute_char() {
1095        let mut state = vim_edit_state("cat\n");
1096        update(Message::SubstituteChar, Size::new(40, 10), &mut state); // s
1097        assert_eq!(state.content, "at\n");
1098        assert!(state.insert_mode(), "s enters insert mode");
1099    }
1100
1101    #[test]
1102    fn test_matching_pair_jumps() {
1103        let mut state = vim_edit_state("(abc)\n");
1104        update(Message::MatchingPair, Size::new(40, 10), &mut state); // % from '(' -> ')'
1105        assert_eq!(state.cursor.source_offset(), 4);
1106    }
1107
1108    #[test]
1109    fn test_paste_before() {
1110        let mut state = vim_edit_state("one\ntwo\n");
1111        let size = Size::new(40, 10);
1112        state.jump_to_offset(4); // the "two" line
1113        update(Message::Operator(Operator::Yank), size, &mut state);
1114        update(Message::Operator(Operator::Yank), size, &mut state); // yy
1115        update(Message::PasteBefore, size, &mut state); // P
1116        assert_eq!(
1117            state.content, "one\ntwo\ntwo\n",
1118            "P pastes the line above the cursor"
1119        );
1120    }
1121}