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
14pub 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
284pub 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 Message::CursorWordForward => {
415 let count = state.take_count().unwrap_or(1);
416 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 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 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 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 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 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); 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); 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); 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); update(Message::InsertMode, size, &mut state); update(Message::TextObjectTarget('"'), size, &mut state); 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 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); update(Message::Append, size, &mut state); 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); 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); 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); 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); 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); update(Message::Operator(Operator::Yank), size, &mut state);
1114 update(Message::Operator(Operator::Yank), size, &mut state); update(Message::PasteBefore, size, &mut state); assert_eq!(
1117 state.content, "one\ntwo\ntwo\n",
1118 "P pastes the line above the cursor"
1119 );
1120 }
1121}