1pub mod autocomplete_glue;
2pub mod backend;
3pub mod find_bar;
4pub mod find_replace;
5pub mod markdown;
6pub mod markdown_edits;
7pub mod nvim_decode;
8pub mod nvim_host;
9pub mod nvim_rpc;
10pub mod parse_incremental;
11pub mod plain_keys;
12mod revisions;
13pub mod rope_buffer;
14pub mod typing_run;
15use revisions::Revisions;
16pub mod snapshot;
17pub mod text_coords;
18pub mod view;
19mod vim;
20mod vim_objects;
21pub mod widener_metrics;
22
23use self::rope_buffer::CursorMove;
24use ratatui::Frame;
25use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
26use ratatui::layout::Rect;
27use ratatui::style::{Modifier, Style};
28use std::num::NonZeroU64;
29
30fn snapshot_from_backend(backend: &BackendState, content_revision: NonZeroU64) -> EditorSnapshot {
37 match backend {
38 BackendState::Textarea(tb) => {
39 let cursor = tb.ta.cursor();
40 EditorSnapshot::of_buffer(tb.ta.text().clone(), cursor, content_revision)
41 }
42 BackendState::Nvim(nvim) => {
43 let snap = nvim.snapshot();
44 let lines_len = snap.lines.len();
45 let cursor_row = if lines_len == 0 {
46 0
47 } else {
48 snap.cursor.0.min(lines_len - 1)
49 };
50 let cursor = (cursor_row, snap.cursor.1);
51 let lines = snap.lines.clone();
52 let rev = Revisions::rev_from_gen(snap.content_gen);
53 drop(snap);
54 EditorSnapshot::owned(lines, cursor, rev)
55 }
56 }
57}
58
59fn preview_revision(base: NonZeroU64, lines: &[String]) -> NonZeroU64 {
68 use std::collections::hash_map::DefaultHasher;
69 use std::hash::{Hash, Hasher};
70 let mut h = DefaultHasher::new();
71 base.get().hash(&mut h);
72 lines.hash(&mut h);
73 NonZeroU64::new(h.finish()).unwrap_or(NonZeroU64::MIN)
74}
75
76fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
87 let cursor_byte = line
88 .char_indices()
89 .nth(col)
90 .map(|(b, _)| b)
91 .unwrap_or(line.len());
92 line[..cursor_byte]
93 .chars()
94 .rev()
95 .any(|c| c == '[' || c == '#')
96}
97
98use self::backend::BackendState;
99#[cfg(test)]
100use self::find_bar::{BarFocus, SearchStatus};
101use self::markdown::ParsedBuffer;
102use self::nvim_host::NvimHost;
103use self::rope_buffer::RopeBuffer;
104use self::snapshot::EditorSnapshot;
105use self::view::MarkdownEditorView;
106use crate::util::single_slot_task::SingleSlotTask;
107
108pub(super) fn char_col_to_byte(line: &str, char_col: usize) -> usize {
111 line.char_indices()
112 .nth(char_col)
113 .map(|(b, _)| b)
114 .unwrap_or(line.len())
115}
116
117#[derive(Debug, Clone)]
121pub struct ClipboardImage {
122 pub width: usize,
123 pub height: usize,
124 pub rgba: Vec<u8>,
125}
126
127const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
131
132const WHEEL_SCROLL_ROWS: isize = 1;
134
135fn linkable_url(s: &str) -> Option<&str> {
136 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
137}
138
139fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
143 let url = linkable_url(clip)?;
144 let sel = selection.filter(|s| !s.is_empty())?;
145 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
146 Some(format!("[{escaped}]({url})"))
147}
148
149use std::sync::Arc;
150
151use kimun_core::NoteVault;
152
153use crate::components::Component;
154use crate::components::autocomplete::{
155 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
156};
157use crate::components::event_state::EventState;
158use crate::components::events::AppEvent;
159use crate::components::events::AppTx;
160use crate::components::events::InputEvent;
161use crate::components::events::redraw_callback;
162use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
163use crate::keys::KeyBindings;
164use crate::keys::action_shortcuts::TextAction;
165use crate::settings::AppSettings;
166use crate::settings::themes::Theme;
167
168#[derive(Debug, Clone, PartialEq)]
176pub enum FollowTarget {
177 Link(String),
179 Label(String),
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum EditorClaim {
192 #[default]
193 None,
194 FindBar,
195 Autocomplete,
196}
197
198struct EditorHostSnapshot {
205 snap: EditorSnapshot,
206 cursor_screen: Option<(u16, u16)>,
207 cache_key: Option<NonZeroU64>,
208}
209
210impl AutocompleteHost for EditorHostSnapshot {
211 fn buffer_snapshot(&self) -> EditorSnapshot {
212 EditorSnapshot::of_buffer(
217 self.snap.text.clone(),
218 self.snap.cursor,
219 self.snap.content_revision,
220 )
221 }
222 fn cache_key(&self) -> Option<NonZeroU64> {
223 self.cache_key
224 }
225 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
226 Some(self.cursor_screen.unwrap_or((0, 0)))
240 }
241}
242
243fn build_editor_host_snapshot(
249 backend: &BackendState,
250 content_revision: NonZeroU64,
251 cursor_screen: Option<(u16, u16)>,
252) -> Option<EditorHostSnapshot> {
253 if !backend.is_textarea() {
254 return None;
255 }
256 Some(EditorHostSnapshot {
257 snap: snapshot_from_backend(backend, content_revision),
258 cursor_screen,
259 cache_key: Some(content_revision),
260 })
261}
262
263pub struct TextEditorComponent {
267 backend: BackendState,
268 note: Option<kimun_core::nfs::VaultPath>,
274 drag_origin: Option<(usize, usize)>,
277 rect: Rect,
279 key_bindings: KeyBindings,
280 view: MarkdownEditorView,
281 revs: Revisions,
290 selection: Option<((usize, usize), (usize, usize))>,
293 nvim_host: NvimHost,
296 search: Option<find_bar::FindBar>,
298 autocomplete: Option<AutocompleteController>,
302 autocomplete_vault: Option<Arc<NoteVault>>,
306 autocomplete_redraw_bound: bool,
311 full_parse_task: SingleSlotTask<()>,
318 layout_task: SingleSlotTask<()>,
325 last_insert_session: bool,
329 pub wants_context_menu: bool,
332 search_needles: Vec<String>,
336 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
337 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
338 layout_tx: tokio::sync::mpsc::UnboundedSender<(u64, crate::ropetext::Layout)>,
339 layout_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, crate::ropetext::Layout)>,
340 redraw_tx: Option<AppTx>,
344}
345
346impl TextEditorComponent {
347 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
348 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
349 let (layout_tx, layout_rx) = tokio::sync::mpsc::unbounded_channel();
350 Self {
351 backend: BackendState::from_settings(
352 &settings.editor_backend,
353 settings.nvim_path.as_ref(),
354 ),
355 rect: Rect::default(),
356 key_bindings,
357 view: MarkdownEditorView::new(),
358 revs: Revisions::new(),
359 selection: None,
360 nvim_host: NvimHost::new(),
361 search: None,
362 note: None,
363 drag_origin: None,
364 autocomplete: None,
365 autocomplete_vault: None,
366 autocomplete_redraw_bound: false,
367 full_parse_task: SingleSlotTask::empty(),
368 layout_task: SingleSlotTask::empty(),
369 last_insert_session: false,
370 wants_context_menu: false,
371 search_needles: Vec::new(),
372 full_parse_tx,
373 full_parse_rx,
374 layout_tx,
375 layout_rx,
376 redraw_tx: None,
377 }
378 }
379
380 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
385 self.autocomplete_vault = Some(vault.clone());
386 if self.backend.is_textarea() {
387 self.autocomplete = Some(AutocompleteController::new(
388 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
389 AutocompleteMode::Both,
390 ));
391 }
392 }
393
394 fn ensure_autocomplete_for_textarea(&mut self) {
399 if self.autocomplete.is_some() {
400 return;
401 }
402 if !self.backend.is_textarea() {
403 return;
404 }
405 let Some(vault) = self.autocomplete_vault.clone() else {
406 return;
407 };
408 self.autocomplete = Some(AutocompleteController::new(
409 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
410 AutocompleteMode::Both,
411 ));
412 self.autocomplete_redraw_bound = false;
415 }
416
417 #[allow(dead_code)]
424 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot> {
425 build_editor_host_snapshot(
426 &self.backend,
427 self.revs.current(),
428 self.view.last_cursor_screen,
429 )
430 }
431
432 fn poll_autocomplete(&mut self) {
435 if let Some(controller) = self.autocomplete.as_mut() {
436 controller.poll_results();
437 }
438 }
439
440 fn textarea_cursor(&self) -> Option<(usize, usize)> {
444 let ta = self.backend.as_textarea()?;
445 Some(ta.cursor())
446 }
447
448 fn refresh_autocomplete_if_open(&mut self) {
449 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
451 return;
452 }
453 let Some(snapshot) = build_editor_host_snapshot(
457 &self.backend,
458 self.revs.current(),
459 self.view.last_cursor_screen,
460 ) else {
461 self.close_autocomplete();
462 return;
463 };
464 if let Some(controller) = self.autocomplete.as_mut() {
465 controller.refresh_if_open(&snapshot);
466 }
467 }
468
469 fn sync_autocomplete(&mut self) {
473 let Some(controller) = self.autocomplete.as_ref() else {
474 return; };
476
477 if !controller.is_open() {
490 let Some(ta) = self.backend.as_textarea() else {
491 return;
492 };
493 let (row, col) = ta.cursor();
494 let line = ta.row(row).unwrap_or_default();
495 if !has_trigger_before_cursor(&line, col) {
496 return;
497 }
498 }
499
500 let Some(snapshot) = build_editor_host_snapshot(
504 &self.backend,
505 self.revs.current(),
506 self.view.last_cursor_screen,
507 ) else {
508 if let Some(c) = self.autocomplete.as_mut() {
509 c.close();
510 }
511 return;
512 };
513 if let Some(controller) = self.autocomplete.as_mut() {
514 controller.sync(&snapshot);
515 }
516 }
517
518 pub fn text(&self) -> crate::ropetext::Text {
525 match &self.backend {
526 BackendState::Textarea(tb) => tb.ta.text().clone(),
527 BackendState::Nvim(_) => crate::ropetext::Text::new(),
528 }
529 }
530
531 pub fn view_snapshot(&self) -> EditorSnapshot {
550 snapshot_from_backend(&self.backend, self.revs.current())
551 }
552
553 pub fn cursor_pos(&self) -> (usize, usize) {
557 self.backend.cursor()
558 }
559
560 pub fn set_search_needles(&mut self, needles: Vec<String>) {
564 self.search_needles = needles
565 .into_iter()
566 .map(|n| n.to_lowercase())
567 .filter(|n| !n.is_empty())
568 .collect();
569 self.revs.arm_needles();
570 }
571
572 pub fn open_note(&mut self, note: kimun_core::nfs::VaultPath, text: String) {
579 if self.note.as_ref().is_some_and(|open| open.is_like(¬e)) {
580 self.set_text(text);
581 return;
582 }
583 self.note = Some(note);
584 self.load(text);
585 }
586
587 pub fn renamed_to(&mut self, note: kimun_core::nfs::VaultPath) {
591 self.note = Some(note);
592 }
593
594 pub fn set_text(&mut self, text: String) {
606 if text == self.get_text() {
607 self.revs.mark_saved_current();
608 if let Some(nvim) = self.backend.as_nvim() {
609 nvim.mark_clean();
610 }
611 return;
612 }
613 self.load(text);
614 }
615
616 fn load(&mut self, text: String) {
619 self.drag_origin = None;
621 match &mut self.backend {
622 BackendState::Textarea(tb) => {
623 tb.ta.replace(crate::ropetext::Text::from(text.as_str()));
624 }
625 BackendState::Nvim(nvim) => {
626 nvim.set_text(&text);
627 }
628 }
629 self.backend.reset_input_state();
630 self.bump_content();
631 let reconstructed = self.get_text();
632 self.mark_saved(reconstructed);
633 self.close_autocomplete();
636 self.search = None;
645 self.selection = None;
646 self.view.note_bulk_edit();
651 }
652
653 pub fn get_text(&self) -> String {
654 self.backend.text()
655 }
656
657 pub fn content_revision(&self) -> NonZeroU64 {
664 self.revs.current()
665 }
666
667 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
677 if !self.revs.mark_saved_at(rev) {
678 return;
679 }
680 self.interrupt_typing();
684 if let Some(nvim) = self.backend.as_nvim() {
685 nvim.mark_clean();
686 }
687 }
688
689 pub fn mark_saved(&mut self, text: String) {
697 self.interrupt_typing();
698 let matches = text == self.get_text();
699 if matches {
700 if let Some(nvim) = self.backend.as_nvim() {
701 nvim.mark_clean();
702 }
703 self.revs.mark_saved_current();
704 } else {
705 self.revs.mark_diverged();
710 }
711 }
712
713 fn interrupt_typing(&mut self) {
730 self.view.clear_visual_goal();
731 if self.backend.modal_is_insert().unwrap_or(false) {
732 return;
733 }
734 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
735 run.end();
736 }
737 }
738
739 fn sync_insert_session(&mut self) {
746 let in_insert = self.backend.modal_is_insert().unwrap_or(false);
747 if self.last_insert_session == in_insert {
748 return;
749 }
750 self.last_insert_session = in_insert;
751 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
752 run.end();
753 }
754 }
755
756 pub fn is_dirty(&self) -> bool {
757 match &self.backend {
758 BackendState::Textarea(_) => self.revs.is_dirty(),
759 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
760 }
761 }
762
763 pub fn space_leads(&self) -> bool {
772 self.backend.space_leads()
773 }
774
775 pub fn mouse_drives_cursor(&self) -> bool {
783 self.backend.is_textarea()
784 }
785
786 pub fn covers(&self, column: u16, row: u16) -> bool {
796 self.rect
797 .contains(ratatui::layout::Position::new(column, row))
798 }
799
800 pub fn claim(&self) -> EditorClaim {
805 if self.search.is_some() {
806 EditorClaim::FindBar
807 } else if self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
808 EditorClaim::Autocomplete
809 } else {
810 EditorClaim::None
811 }
812 }
813
814 pub fn follow_target_at_cursor(&self) -> Option<FollowTarget> {
817 let (_row, col, line) = match &self.backend {
818 BackendState::Textarea(tb) => {
819 let (row, col) = tb.ta.cursor();
820 let line = tb.ta.row(row)?.into_owned();
821 (row, col, line)
822 }
823 BackendState::Nvim(nvim) => {
824 let snap = nvim.snapshot();
825 let (row, col) = snap.cursor;
826 let line = snap.lines.get(row)?.to_string();
827 (row, col, line)
828 }
829 };
830
831 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
834 .into_iter()
835 .find(|s| s.start <= col && col < s.end)
836 {
837 return Some(FollowTarget::Link(span.target));
838 }
839
840 let parsed = self::markdown::ParsedLine::parse(&line);
842 parsed
843 .elements
844 .iter()
845 .find(|e| {
846 e.kind == self::markdown::ElementKind::Label
847 && col >= e.start_char
848 && col < e.end_char
849 })
850 .map(|e| {
851 let span: String = line
852 .chars()
853 .skip(e.start_char)
854 .take(e.end_char - e.start_char)
855 .collect();
856 let name = span.trim_start_matches('#').to_string();
857 FollowTarget::Label(name)
858 })
859 }
860
861 fn copy_selection_to_clipboard(&mut self, tx: &AppTx) {
867 if let Some(text) = self.backend.copy_visual() {
870 crate::components::yank(text, "copied", tx);
871 return;
872 }
873 let text = {
874 let selected = self
883 .backend
884 .selection_as_shown()
885 .zip(self.backend.as_textarea())
886 .and_then(|(range, ta)| ta.text_between(range.0, range.1));
887 match selected {
888 Some(t) if !t.is_empty() => t,
889 _ => {
890 tx.send(AppEvent::FlashMessage("nothing to copy".into()))
891 .ok();
892 return;
893 }
894 }
895 };
896 crate::components::yank(text, "copied", tx);
897 }
898
899 fn sync_highlight(&mut self) {
907 self.selection = self.backend.selection_as_shown();
908 if self.backend.is_visual_line()
909 && let Some((start, (er, _))) = self.selection
910 {
911 self.selection = Some((start, (er, usize::MAX)));
912 }
913 }
914
915 fn paste_from_clipboard(&mut self, tx: &AppTx) {
919 let text = match crate::components::with_clipboard(|c| c.get_text()) {
920 Ok(t) if !t.is_empty() => t,
921 Ok(_) => {
922 tx.send(AppEvent::FlashMessage("clipboard is empty".into()))
923 .ok();
924 return;
925 }
926 Err(e) => {
927 tx.send(AppEvent::FlashMessage(format!("clipboard: {e}")))
928 .ok();
929 return;
930 }
931 };
932 self.paste_text(&text, tx);
933 tx.send(AppEvent::FlashMessage("pasted".into())).ok();
937 }
938
939 fn extend_visual_selection_inclusive(&mut self) {
948 if let Some((start, end)) = self.backend.visual_range()
949 && let Some(ta) = self.backend.as_textarea_mut()
950 {
951 ta.set_selection(start, end);
952 }
953 }
954
955 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
964 if text.is_empty() {
965 return;
966 }
967 if self.search.is_some() {
974 if let (Some(bar), BackendState::Textarea(tb)) =
975 (self.search.as_mut(), &mut self.backend)
976 {
977 bar.paste(text, &mut tb.ta);
978 }
979 self.apply_edit_outcome();
980 return;
981 }
982 self.backend.conclude_visual();
985 self.extend_visual_selection_inclusive();
986 match &mut self.backend {
987 BackendState::Textarea(tb) => {
988 let selection = linkable_url(text).and_then(|_| tb.ta.selection_text());
989 let wrapped = try_build_markdown_link(text, selection.as_deref());
990 let insert = wrapped.as_deref().unwrap_or(text).to_string();
991 tb.ta.edit(|ta| {
994 if ta.selection_range().is_some() {
995 ta.cut();
996 }
997 ta.insert_str(insert);
998 });
999 self.backend.sync_mouse_selection(false);
1000 self.after_edit();
1001 }
1002 BackendState::Nvim(nvim) => {
1003 nvim.paste(text, tx.clone());
1004 self.bump_content();
1005 }
1006 }
1007 self.bind_autocomplete_redraw(tx);
1011 self.sync_autocomplete();
1012 }
1013
1014 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
1019 if matches!(self.backend, BackendState::Nvim(_)) {
1020 self.paste_text(text, tx);
1021 return;
1022 }
1023 self.take_selection_for_external_paste();
1030 if let Some(ta) = self.backend.as_textarea_mut() {
1031 ta.insert_str(text);
1032 self.after_edit();
1033 }
1034 self.bind_autocomplete_redraw(tx);
1037 self.sync_autocomplete();
1038 }
1039
1040 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
1050 let img = crate::components::with_clipboard(|c| c.get_image()).ok()?;
1051 Some(ClipboardImage {
1052 width: img.width,
1053 height: img.height,
1054 rgba: img.bytes.into_owned(),
1055 })
1056 }
1057
1058 pub fn take_selection_for_external_paste(&mut self) {
1067 self.backend.conclude_visual();
1070 self.extend_visual_selection_inclusive();
1071 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1072 let cut = ta.selection_range().is_some() && ta.cut();
1073 self.selection = ta.selection_range();
1074 cut
1075 } else {
1076 false
1077 };
1078 if cut {
1079 self.apply_edit_outcome();
1080 }
1081 self.backend.sync_mouse_selection(false);
1084 }
1085
1086 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1091 self.extend_visual_selection_inclusive();
1092 let Some(ta) = self.backend.as_textarea_mut() else {
1093 return false;
1094 };
1095 if !markdown_edits::wrap_selection(ta, open, close) {
1096 return false;
1097 }
1098 self.backend.adopt_host_selection();
1101 self.after_edit();
1105 true
1106 }
1107
1108 pub fn apply_text_action(&mut self, action: TextAction) {
1112 let Some(marker) = markdown_edits::emphasis_marker(action) else {
1113 return;
1114 };
1115 if self.backend.visual_is_empty() {
1118 return;
1119 }
1120 if self.wrap_selection(marker, marker) {
1121 return;
1122 }
1123 let Some(ta) = self.backend.as_textarea_mut() else {
1124 return;
1125 };
1126 markdown_edits::insert_pair(ta, marker, marker);
1127 self.after_edit();
1128 }
1129
1130 pub fn smart_enter(&mut self) -> bool {
1134 let Some(ta) = self.backend.as_textarea_mut() else {
1135 return false;
1136 };
1137 if !markdown_edits::smart_enter(ta) {
1138 return false;
1139 }
1140 self.after_edit();
1141 true
1142 }
1143
1144 pub fn jump_to_row(&mut self, row: usize) {
1147 let Some(ta) = self.backend.as_textarea_mut() else {
1148 return;
1149 };
1150 if markdown_edits::jump_to_row(ta, row) {
1151 self.sync_highlight();
1152 }
1153 }
1154
1155 pub fn indent_lines(&mut self, dedent: bool) {
1160 let Some(ta) = self.backend.as_textarea_mut() else {
1161 return;
1162 };
1163 let rows = match ta.selection_range() {
1164 Some(((sr, _), (er, ec))) => {
1165 let last = if ec == 0 && er > sr { er - 1 } else { er };
1166 sr..=last
1167 }
1168 None => {
1169 let (row, _) = ta.cursor();
1170 row..=row
1171 }
1172 };
1173 if ta.indent_rows(rows, dedent) {
1174 self.after_edit();
1175 }
1176 }
1177}
1178
1179impl TextEditorComponent {
1180 #[inline]
1190 fn bump_content(&mut self) {
1191 self.revs.bump();
1192 }
1193
1194 fn maybe_recover_from_dead_nvim(&mut self) {
1196 if self.backend.recover_from_dead_nvim() {
1197 self.ensure_autocomplete_for_textarea();
1201 }
1202 }
1203
1204 fn handle_nvim_key(
1209 &mut self,
1210 key: &ratatui::crossterm::event::KeyEvent,
1211 tx: &AppTx,
1212 ) -> Option<EventState> {
1213 let nvim = self.backend.as_nvim()?;
1217 self.nvim_host.handle_key(nvim, key, tx);
1222 Some(EventState::Consumed)
1223 }
1224
1225 pub fn open_or_advance_search(&mut self) {
1229 if !self.backend.is_textarea() {
1230 return;
1231 }
1232 if self.search.is_some() {
1233 self.dispatch_bar(|bar, buf| {
1234 bar.advance(buf, false);
1235 find_bar::KeyOutcome::default()
1236 });
1237 return;
1238 }
1239 self.close_autocomplete();
1242 self.search = Some(find_bar::FindBar::new());
1243 }
1244
1245 pub fn open_replace(&mut self) {
1248 if !self.backend.is_textarea() {
1249 return;
1250 }
1251 if self.search.is_none() {
1252 self.close_autocomplete();
1253 self.search = Some(find_bar::FindBar::new());
1254 }
1255 if let Some(bar) = self.search.as_mut() {
1256 bar.reveal_replace();
1257 }
1258 }
1259
1260 fn search_repeat(&mut self, backward: bool) {
1263 let BackendState::Textarea(tb) = &mut self.backend else {
1264 return;
1265 };
1266 self.selection = if tb.ta.search_repeat(backward) {
1269 tb.ta.match_at_cursor()
1270 } else {
1271 None
1272 };
1273 }
1274
1275 fn dispatch_bar(
1278 &mut self,
1279 f: impl FnOnce(&mut find_bar::FindBar, &mut RopeBuffer) -> find_bar::KeyOutcome,
1280 ) -> bool {
1281 let BackendState::Textarea(tb) = &mut self.backend else {
1282 return false;
1283 };
1284 let Some(bar) = self.search.as_mut() else {
1285 return false;
1286 };
1287 let outcome = f(bar, &mut tb.ta);
1288 if outcome.close {
1289 self.search = None;
1290 tb.ta.cancel_selection();
1296 self.selection = None;
1297 }
1298 self.apply_edit_outcome();
1299 true
1300 }
1301
1302 fn dispatch_to_find_bar(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1304 self.dispatch_bar(|bar, buf| bar.handle_key(key, buf))
1305 }
1306
1307 #[cfg(test)]
1310 fn replace_preview(&self) -> Option<find_replace::Preview> {
1311 let bar = self.search.as_ref()?;
1312 let buf = self.backend.as_textarea()?;
1313 bar.preview(buf)
1314 }
1315
1316 pub fn close_autocomplete(&mut self) {
1320 if let Some(c) = self.autocomplete.as_mut() {
1321 c.close();
1322 }
1323 }
1324
1325 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1330 self.bind_autocomplete_redraw(tx);
1331 }
1332
1333 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1342 if self.redraw_tx.is_none() {
1343 self.redraw_tx = Some(tx.clone());
1344 }
1345 if self.autocomplete_redraw_bound {
1346 return;
1347 }
1348 if let Some(c) = self.autocomplete.as_mut() {
1349 c.set_redraw_callback(redraw_callback(tx.clone()));
1350 self.autocomplete_redraw_bound = true;
1351 }
1352 }
1353
1354 fn after_edit(&mut self) -> bool {
1360 self.interrupt_typing();
1361 self.sync_highlight();
1362 self.apply_edit_outcome()
1363 }
1364
1365 fn apply_edit_outcome(&mut self) -> bool {
1376 let Some(outcome) = self.backend.as_textarea_mut().map(|ta| ta.take_outcome()) else {
1377 return false;
1378 };
1379 if outcome.changed {
1380 self.bump_content();
1381 }
1382 if outcome.bulk {
1383 self.view.note_bulk_edit();
1384 }
1385 if let Some(rows) = outcome.damage {
1386 self.view.note_damage(rows, outcome.line_delta);
1387 }
1388 outcome.changed
1389 }
1390
1391 fn undo_grouped(&mut self) -> bool {
1394 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.undo());
1395 if moved {
1396 self.sync_highlight();
1397 }
1398 moved
1399 }
1400
1401 fn redo_grouped(&mut self) -> bool {
1403 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.redo());
1404 if moved {
1405 self.sync_highlight();
1406 }
1407 moved
1408 }
1409
1410 fn handle_textarea_key(
1412 &mut self,
1413 key: &ratatui::crossterm::event::KeyEvent,
1414 tx: &AppTx,
1415 ) -> EventState {
1416 let stroke = plain_keys::operation(*key).and_then(|op| match op {
1426 plain_keys::Operation::Insert(c) => Some(typing_run::Stroke::Insert(c)),
1427 plain_keys::Operation::InsertNewline => Some(typing_run::Stroke::Insert('\n')),
1428 plain_keys::Operation::DeleteBack | plain_keys::Operation::DeleteForward => {
1429 Some(typing_run::Stroke::Delete)
1430 }
1431 _ => None,
1432 });
1433 if stroke.is_none()
1434 && let Some((_, run)) = self.backend.as_textarea_parts_mut()
1435 {
1436 run.end();
1437 }
1438
1439 if key.modifiers == KeyModifiers::CONTROL {
1441 match key.code {
1442 KeyCode::Char('c') => {
1443 self.copy_selection_to_clipboard(tx);
1444 return EventState::Consumed;
1445 }
1446 KeyCode::Char('v') => {
1447 self.paste_from_clipboard(tx);
1448 return EventState::Consumed;
1449 }
1450 KeyCode::Char('x') => {
1451 self.copy_selection_to_clipboard(tx);
1452 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1453 let cut = ta.cut();
1459 self.selection = ta.selection_range();
1460 cut
1461 } else {
1462 false
1463 };
1464 if cut {
1465 self.apply_edit_outcome();
1466 }
1467 return EventState::Consumed;
1468 }
1469 _ => {}
1470 }
1471 }
1472
1473 if key.modifiers & !KeyModifiers::SHIFT == KeyModifiers::CONTROL {
1478 match key.code {
1479 KeyCode::Char('z') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
1480 if self.undo_grouped() {
1481 self.apply_edit_outcome();
1482 }
1483 return EventState::Consumed;
1484 }
1485 KeyCode::Char('y') | KeyCode::Char('Z') => {
1486 if self.redo_grouped() {
1487 self.apply_edit_outcome();
1488 }
1489 return EventState::Consumed;
1490 }
1491 _ => {}
1492 }
1493 }
1494
1495 match (key.modifiers, key.code) {
1507 (m, KeyCode::Tab)
1508 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1509 {
1510 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1511 return EventState::Consumed;
1512 }
1513 (_, KeyCode::BackTab) => {
1514 self.indent_lines(true);
1515 return EventState::Consumed;
1516 }
1517 _ => {}
1518 }
1519 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1520 return EventState::Consumed;
1521 }
1522
1523 if let KeyCode::Char(c) = key.code
1530 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1531 && let Some((open, close)) = markdown_edits::surround_pair(c)
1532 && self.wrap_selection(open, close)
1533 {
1534 return EventState::Consumed;
1535 }
1536
1537 self.sync_insert_session();
1543
1544 let in_insert_session = self.last_insert_session;
1546 let Some((ta, run)) = self.backend.as_textarea_parts_mut() else {
1547 unreachable!("handle_textarea_key called with non-Textarea backend")
1548 };
1549 if let Some(op) = plain_keys::operation(*key) {
1554 let vertical = match op {
1558 plain_keys::Operation::Move {
1559 to: CursorMove::Up,
1560 extend,
1561 } => Some((false, extend)),
1562 plain_keys::Operation::Move {
1563 to: CursorMove::Down,
1564 extend,
1565 } => Some((true, extend)),
1566 _ => None,
1567 };
1568 if vertical.is_none() {
1569 self.view.clear_visual_goal();
1570 }
1571 if let Some(stroke) = stroke {
1577 {
1578 let now = std::time::Instant::now();
1579 let carries_on = if in_insert_session {
1584 run.continues_session(stroke, now)
1585 } else {
1586 run.continues(stroke, now)
1587 };
1588 if carries_on {
1589 ta.continue_group();
1590 }
1591 }
1592 }
1593
1594 let changed = match vertical {
1595 Some((down, extend)) if self.view.move_cursor_visually(ta, down, extend) => false,
1598 _ => plain_keys::apply(op, ta),
1599 };
1600 self.selection = ta.selection_range();
1601 if changed {
1602 self.apply_edit_outcome();
1603 }
1604 }
1605 EventState::Consumed
1609 }
1610
1611 fn apply_popup_outcome(&mut self, outcome: HandleKeyOutcome) -> Option<EventState> {
1615 match outcome {
1616 HandleKeyOutcome::Accepted(action) => {
1617 self.interrupt_typing();
1618 if let Some(ta) = self.backend.as_textarea_mut() {
1619 ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1620 }
1621 self.after_edit();
1622 Some(EventState::Consumed)
1623 }
1624 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => Some(EventState::Consumed),
1625 HandleKeyOutcome::NotHandled => None,
1626 }
1627 }
1628
1629 fn handle_mouse(
1631 &mut self,
1632 mouse: &ratatui::crossterm::event::MouseEvent,
1633 tx: &AppTx,
1634 ) -> EventState {
1635 if !self.covers(mouse.column, mouse.row) {
1636 return EventState::NotConsumed;
1637 }
1638 let r = self.rect;
1641 self.interrupt_typing();
1645 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1649 && self.selection.is_none_or(|(start, end)| start == end)
1650 {
1651 self.wants_context_menu = true;
1652 return EventState::Consumed;
1653 }
1654 if !self.backend.is_textarea() {
1658 return EventState::NotConsumed;
1659 }
1660 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1662 self.copy_selection_to_clipboard(tx);
1663 self.sync_highlight();
1664 return EventState::Consumed;
1665 }
1666 if matches!(mouse.kind, MouseEventKind::Drag(_)) {
1670 let (lrow, lcol) = self
1671 .view
1672 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1673 match self.drag_origin {
1674 Some(origin)
1675 if self
1676 .backend
1677 .select_dragged(origin, (lrow as usize, lcol as usize)) =>
1678 {
1679 self.sync_highlight();
1680 return EventState::Consumed;
1681 }
1682 None if self.backend.is_vim() => return EventState::Consumed,
1683 _ => {}
1684 }
1685 }
1686 if matches!(mouse.kind, MouseEventKind::Up(_)) {
1688 self.drag_origin = None;
1689 }
1690 let Some(ta) = self.backend.as_textarea_mut() else {
1692 unreachable!()
1693 };
1694 match mouse.kind {
1695 MouseEventKind::Down(_) => {
1696 self.view.follow_cursor();
1697 ta.cancel_selection();
1698 let (lrow, lcol) = self
1699 .view
1700 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1701 ta.jump_to(lrow as usize, lcol as usize);
1702 ta.start_selection();
1703 self.drag_origin = Some(ta.cursor());
1704 }
1705 MouseEventKind::Drag(_) => {
1706 let (lrow, lcol) = self
1707 .view
1708 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1709 ta.jump_to(lrow as usize, lcol as usize);
1710 }
1711 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1714 let delta = if mouse.kind == MouseEventKind::ScrollUp {
1715 -WHEEL_SCROLL_ROWS
1716 } else {
1717 WHEEL_SCROLL_ROWS
1718 };
1719 if self.view.scroll_by(delta)
1722 && let Some(c) = self.autocomplete.as_mut()
1723 {
1724 c.close();
1725 }
1726 }
1727 _ => {}
1730 }
1731 self.selection = ta.selection_range();
1732 EventState::Consumed
1735 }
1736}
1737
1738impl Component for TextEditorComponent {
1743 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1744 self.maybe_recover_from_dead_nvim();
1745 self.bind_autocomplete_redraw(tx);
1746
1747 match event {
1748 InputEvent::Key(key) => {
1749 self.view.follow_cursor();
1753 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1761 if popup_open
1762 && let Some(host) = build_editor_host_snapshot(
1763 &self.backend,
1764 self.revs.current(),
1765 self.view.last_cursor_screen,
1766 )
1767 && let Some(controller) = self.autocomplete.as_mut()
1768 {
1769 let outcome = controller.handle_key(*key, &host);
1770 if let Some(state) = self.apply_popup_outcome(outcome) {
1771 return state;
1772 }
1773 }
1774 if self.dispatch_to_find_bar(key) {
1779 self.interrupt_typing();
1783 return EventState::Consumed;
1784 }
1785 if let Some(outcome) = self.backend.vim_handle_key(key) {
1790 use self::vim::VimKeyOutcome;
1791 if !matches!(outcome, VimKeyOutcome::PassThrough) {
1796 self.interrupt_typing();
1797 }
1798 self.apply_edit_outcome();
1802 match outcome {
1803 VimKeyOutcome::TextMutated => {
1804 self.selection = None;
1807 return EventState::Consumed;
1808 }
1809 VimKeyOutcome::CursorOnly => {
1810 self.sync_highlight();
1815 self.refresh_autocomplete_if_open();
1816 return EventState::Consumed;
1817 }
1818 VimKeyOutcome::NoOp => return EventState::Consumed,
1819 VimKeyOutcome::PassThrough => { }
1820 VimKeyOutcome::Host(action) => {
1821 use self::vim::VimHostAction;
1822 match action {
1823 VimHostAction::OpenPalette => {
1824 tx.send(AppEvent::ExecuteLeaderAction(
1826 crate::keys::leader::LeaderAction::Palette,
1827 ))
1828 .ok();
1829 }
1830 VimHostAction::OpenSearch { forward: _ } => {
1831 self.open_or_advance_search();
1835 }
1836 VimHostAction::SearchNext => self.search_repeat(false),
1837 VimHostAction::SearchPrev => self.search_repeat(true),
1838 VimHostAction::ClipboardCopy(text) => {
1842 self.selection = None;
1843 crate::components::yank(text, "copied", tx);
1844 }
1845 VimHostAction::ClipboardCut(text) => {
1846 self.selection = None;
1847 crate::components::yank(text, "cut", tx);
1848 }
1849 VimHostAction::ClipboardPaste => {
1855 self.selection = self
1856 .backend
1857 .as_textarea()
1858 .and_then(|ta| ta.selection_range());
1859 self.paste_from_clipboard(tx);
1860 }
1861 }
1862 return EventState::Consumed;
1863 }
1864 }
1865 }
1866 if let Some(state) = self.handle_nvim_key(key, tx) {
1867 return state;
1868 }
1869 let text_rev_before = self.revs.current();
1880 let cursor_before = self.textarea_cursor();
1881 let result = self.handle_textarea_key(key, tx);
1882 let cursor_after = self.textarea_cursor();
1883 if self.revs.current() != text_rev_before {
1884 self.sync_autocomplete();
1885 } else if cursor_before != cursor_after {
1886 self.refresh_autocomplete_if_open();
1887 }
1888 result
1889 }
1890 InputEvent::Mouse(mouse) => {
1891 if self.autocomplete.as_ref().is_some_and(|c| c.is_open())
1895 && let Some(host) = build_editor_host_snapshot(
1896 &self.backend,
1897 self.revs.current(),
1898 self.view.last_cursor_screen,
1899 )
1900 && let Some(controller) = self.autocomplete.as_mut()
1901 {
1902 let outcome = controller.handle_mouse(mouse, &host);
1903 if let Some(state) = self.apply_popup_outcome(outcome) {
1904 return state;
1905 }
1906 }
1907 let text_rev_before = self.revs.current();
1908 let cursor_before = self.textarea_cursor();
1909 let result = self.handle_mouse(mouse, tx);
1910 let cursor_after = self.textarea_cursor();
1911 if self.revs.current() != text_rev_before {
1914 self.sync_autocomplete();
1915 } else if cursor_before != cursor_after {
1916 self.refresh_autocomplete_if_open();
1917 }
1918 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1931 self.backend.sync_mouse_selection(false);
1932 }
1933 self.sync_highlight();
1934 result
1935 }
1936 InputEvent::Paste(_) => EventState::NotConsumed,
1939 }
1940 }
1941
1942 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1943 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
1948 let bar_rows = bar_rows.min(rect.height);
1953 let (editor_rect, search_rect) = if bar_rows > 0 {
1954 (
1955 Rect {
1956 height: rect.height - bar_rows,
1957 ..rect
1958 },
1959 Some(Rect {
1960 y: rect.y + rect.height - bar_rows,
1961 height: bar_rows,
1962 ..rect
1963 }),
1964 )
1965 } else {
1966 (rect, None)
1967 };
1968 self.rect = editor_rect;
1971 let selection = match &self.backend {
1976 BackendState::Textarea(_) => match self.search.as_ref() {
1979 Some(bar) => bar.current_match(),
1980 None => self.selection,
1981 },
1982 BackendState::Nvim(nvim) => {
1983 self.nvim_host
1984 .frame_sync(nvim, editor_rect.width, editor_rect.height)
1985 }
1986 };
1987 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1993 self.view.install_full_parse(generation, buf);
1994 }
1995 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
1999 self.view.install_full_layout(generation, layout);
2000 }
2001
2002 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
2013 (Some(bar), Some(buf)) => bar.overlay(buf),
2014 _ => find_bar::BarOverlay::default(),
2015 };
2016 let preview = overlay.preview;
2017 let snap = snapshot_from_backend(&self.backend, self.revs.current());
2018 self.revs.adopt(snap.content_revision);
2024 let (view_lines, preview_spans) = match preview {
2028 None => (None, Vec::new()),
2029 Some(p) => (Some(p.lines), p.spans),
2030 };
2031 match &view_lines {
2032 None => self.view.update(&snap, editor_rect),
2033 Some(lines) => {
2034 let rev = preview_revision(snap.content_revision, lines);
2039 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
2040 self.view.update(&view_snap, editor_rect);
2041 }
2042 }
2043 if self.revs.needles_stale() {
2047 self.search_needles.clear();
2048 self.revs.disarm_needles();
2049 }
2050 self.view.set_needles(self.search_needles.clone());
2051
2052 let mut overlays: Vec<view::Overlay> = Vec::new();
2055 if let Some(((sr, sc), (er, ec))) = selection {
2056 for row in sr..=er {
2059 let start = if row == sr { sc } else { 0 };
2060 let end = if row == er { ec } else { usize::MAX };
2061 overlays.push(view::Overlay::new(
2062 row,
2063 start,
2064 end,
2065 view::OverlayKind::Selection,
2066 ));
2067 }
2068 }
2069 overlays.extend(preview_spans.iter().map(|p| {
2070 view::Overlay::new(
2071 p.row,
2072 p.start,
2073 p.end,
2074 if p.is_current {
2075 view::OverlayKind::PreviewCurrent
2076 } else {
2077 view::OverlayKind::Preview
2078 },
2079 )
2080 }));
2081 if view_lines.is_none() {
2084 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2085 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2086 }));
2087 }
2088 self.view.set_overlays(overlays);
2089
2090 if let Some(generation) = self.view.take_pending_full_parse() {
2098 let text = match &view_lines {
2110 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2111 None => snap.text.clone(),
2112 };
2113 let tx = self.full_parse_tx.clone();
2114 let redraw = self.redraw_tx.clone();
2115 self.full_parse_task.spawn(async move {
2116 let buf = ParsedBuffer::parse(&text);
2117 let _ = tx.send((generation, buf));
2118 if let Some(redraw) = redraw {
2121 let _ = redraw.send(AppEvent::Redraw);
2122 }
2123 });
2124 }
2125 if let Some(job) = self.view.take_pending_full_layout() {
2130 let tx = self.layout_tx.clone();
2131 let redraw = self.redraw_tx.clone();
2132 self.layout_task.spawn(async move {
2133 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2134 let layout = crate::ropetext::Layout::compute(
2135 &job.text,
2136 job.width,
2137 crate::ropetext::Metrics::default(),
2138 &hints,
2139 );
2140 let _ = tx.send((job.generation, layout));
2141 if let Some(redraw) = redraw {
2142 let _ = redraw.send(AppEvent::Redraw);
2143 }
2144 });
2145 }
2146 let bar_focused = self.search.is_some() && focused;
2149 let editor_focused = focused && !bar_focused;
2150 use self::view::CursorShape;
2151 let cursor_shape = match self.backend.modal_is_insert() {
2152 None => None, Some(true) => Some(CursorShape::Bar),
2154 Some(false) => Some(CursorShape::Block),
2155 };
2156 self.view
2157 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2158
2159 if self.revs.needles_stale() {
2163 self.search_needles.clear();
2164 self.revs.disarm_needles();
2165 }
2166
2167 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2171 let leader = self
2172 .key_bindings
2173 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2174 .unwrap_or_else(|| "leader".to_string());
2175 f.render_widget(
2176 ratatui::widgets::Paragraph::new(format!(
2177 "Type to start · [[ to link · # to tag · {leader} for commands"
2178 ))
2179 .style(
2180 Style::default()
2181 .fg(theme.gray.to_ratatui())
2182 .add_modifier(Modifier::ITALIC),
2183 ),
2184 Rect {
2185 x: editor_rect.x.saturating_add(2),
2186 width: editor_rect.width.saturating_sub(2),
2187 height: 1,
2188 ..editor_rect
2189 },
2190 );
2191 }
2192 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2193 state.render(f, bar_rect, theme, bar_focused);
2194 }
2195
2196 self.poll_autocomplete();
2204 let live_anchor = self.view.last_cursor_screen;
2211 if let Some(state) = self.autocomplete.as_mut().and_then(|c| c.state_mut()) {
2212 match live_anchor {
2213 Some(anchor) => {
2214 state.anchor = anchor;
2215 autocomplete::render(f, state, editor_rect, theme);
2216 }
2217 None => state.rows_rect = Rect::default(),
2219 }
2220 }
2221 }
2222
2223 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2224 use crate::keys::action_shortcuts::ActionShortcuts;
2225
2226 if let Some(mut label) = self.backend.mode_label() {
2231 if let Some(p) = self.backend.pending_input_hint() {
2232 label = format!("{label} {p}");
2233 }
2234 let mut hints = vec![(String::new(), label)];
2235 hints.extend(
2236 [
2237 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2238 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2239 (ActionShortcuts::FileOperations, "file ops"),
2240 ]
2241 .iter()
2242 .filter_map(|(action, label)| {
2243 self.key_bindings
2244 .first_combo_for(action)
2245 .map(|k| (k, label.to_string()))
2246 }),
2247 );
2248 return hints;
2249 }
2250
2251 let mut hints: Vec<(String, String)> = Vec::new();
2254 match self.follow_target_at_cursor() {
2255 Some(FollowTarget::Link(_)) => {
2256 if let Some(k) = self
2257 .key_bindings
2258 .first_combo_for(&ActionShortcuts::FollowLink)
2259 {
2260 hints.push((k, "follow link".to_string()));
2261 }
2262 }
2263 Some(FollowTarget::Label(_)) => {
2264 if let Some(k) = self
2265 .key_bindings
2266 .first_combo_for(&ActionShortcuts::FollowLink)
2267 {
2268 hints.push((k, "browse tag".to_string()));
2269 }
2270 }
2271 None => {}
2272 }
2273 hints.extend(crate::components::hints::hints_for(
2274 &self.key_bindings,
2275 &[
2276 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2277 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2278 (ActionShortcuts::FileOperations, "file ops"),
2279 (ActionShortcuts::FindInBuffer, "find"),
2280 ],
2281 ));
2282 hints
2283 }
2284}
2285
2286#[cfg(test)]
2287mod tests {
2288 use super::snapshot::EditorMode;
2289 use super::*;
2290 use crate::keys::KeyBindings;
2291
2292 fn make_editor() -> TextEditorComponent {
2293 TextEditorComponent::new(
2294 KeyBindings::empty(),
2295 &crate::settings::AppSettings::default(),
2296 )
2297 }
2298
2299 fn dummy_tx() -> AppTx {
2300 tokio::sync::mpsc::unbounded_channel().0
2301 }
2302
2303 #[tokio::test]
2306 async fn clicking_an_autocomplete_suggestion_inserts_it() {
2307 use crate::components::search_list::{SuggestionItem, SuggestionSource};
2308 use ratatui::{Terminal, backend::TestBackend};
2309 struct Tags;
2310 #[async_trait::async_trait]
2311 impl SuggestionSource for Tags {
2312 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
2313 vec![]
2314 }
2315 async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
2316 if "projects".starts_with(p) {
2317 vec![SuggestionItem::plain("projects")]
2318 } else {
2319 vec![]
2320 }
2321 }
2322 }
2323 let mut editor = make_editor();
2324 editor.autocomplete = Some(
2325 AutocompleteController::new(std::sync::Arc::new(Tags), AutocompleteMode::Both)
2326 .with_debounce(std::time::Duration::ZERO),
2327 );
2328 let tx = dummy_tx();
2329 let theme = Theme::default();
2330 let mut t = Terminal::new(TestBackend::new(60, 20)).unwrap();
2331 let draw = |editor: &mut TextEditorComponent, t: &mut Terminal<TestBackend>| {
2332 t.draw(|f| editor.render(f, f.area(), &theme, true))
2333 .unwrap();
2334 };
2335 draw(&mut editor, &mut t);
2336 for c in ['#', 'p', 'r', 'o'] {
2337 editor.handle_input(
2338 &InputEvent::Key(ratatui::crossterm::event::KeyEvent::from(
2339 ratatui::crossterm::event::KeyCode::Char(c),
2340 )),
2341 &tx,
2342 );
2343 draw(&mut editor, &mut t);
2344 }
2345 for _ in 0..50 {
2346 tokio::task::yield_now().await;
2347 draw(&mut editor, &mut t);
2348 }
2349 let rows = editor
2350 .autocomplete
2351 .as_ref()
2352 .and_then(|c| c.state())
2353 .map(|st| st.rows_rect)
2354 .filter(|r| !r.is_empty())
2355 .expect("popup drawn");
2356 editor.handle_input(&crate::test_support::mouse_down_at(rows.x + 1, rows.y), &tx);
2357 assert_eq!(editor.get_text(), "#projects");
2358 }
2359
2360 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2361 match &mut editor.backend {
2362 BackendState::Textarea(tb) => &mut tb.ta,
2363 _ => panic!("expected Textarea backend"),
2364 }
2365 }
2366
2367 #[test]
2368 fn has_trigger_before_cursor_finds_bracket() {
2369 assert!(has_trigger_before_cursor("hello [[foo", 11));
2370 assert!(has_trigger_before_cursor("[[a b c", 7));
2371 }
2372
2373 #[test]
2374 fn has_trigger_before_cursor_finds_hashtag() {
2375 assert!(has_trigger_before_cursor("text #tag", 9));
2376 }
2377
2378 #[test]
2379 fn has_trigger_before_cursor_no_trigger_bails() {
2380 assert!(!has_trigger_before_cursor("plain prose here", 16));
2381 assert!(!has_trigger_before_cursor("", 0));
2382 }
2383
2384 #[test]
2385 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2386 let line = "你好世界".to_string() + &"a".repeat(80);
2389 let col = line.chars().count();
2390 assert!(!has_trigger_before_cursor(&line, col));
2391
2392 let with_emoji = "🦀".repeat(20) + "[[note";
2393 let col = with_emoji.chars().count();
2394 assert!(has_trigger_before_cursor(&with_emoji, col));
2395
2396 let accented = "é".repeat(100);
2397 let col = accented.chars().count();
2398 assert!(!has_trigger_before_cursor(&accented, col));
2399 }
2400
2401 #[test]
2402 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2403 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2405 }
2406
2407 #[test]
2408 fn has_trigger_before_cursor_wikilink_with_spaces() {
2409 assert!(has_trigger_before_cursor("[[my note title", 15));
2412 }
2413
2414 #[test]
2415 fn fresh_editor_is_not_dirty() {
2416 let editor = make_editor();
2417 assert!(!editor.is_dirty());
2418 }
2419
2420 #[test]
2421 fn after_set_text_not_dirty() {
2422 let mut editor = make_editor();
2423 editor.set_text("hello world".to_string());
2424 assert!(!editor.is_dirty());
2425 }
2426
2427 #[test]
2428 fn get_text_returns_loaded_content() {
2429 let mut editor = make_editor();
2430 editor.set_text("line one\nline two".to_string());
2431 assert_eq!(editor.get_text(), "line one\nline two");
2432 }
2433
2434 #[test]
2435 fn mark_saved_clears_dirty() {
2436 let mut editor = make_editor();
2437 editor.set_text("initial".to_string());
2438 let text = editor.get_text();
2439 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2441 editor.mark_saved(text); assert!(!editor.is_dirty());
2443 }
2444
2445 #[test]
2446 fn trailing_newline_does_not_cause_false_dirty() {
2447 let mut editor = make_editor();
2448 editor.set_text("content\n".to_string());
2449 assert!(
2450 !editor.is_dirty(),
2451 "trailing newline should not make editor dirty after load"
2452 );
2453 }
2454
2455 #[test]
2456 fn cursor_move_does_not_dirty_buffer() {
2457 let mut editor = make_editor();
2458 editor.set_text("hello world".to_string());
2459 assert!(!editor.is_dirty());
2460 let tx = dummy_tx();
2461 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2464 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2465 assert!(
2466 !editor.is_dirty(),
2467 "cursor move must not mark the editor as dirty"
2468 );
2469 }
2470
2471 #[test]
2472 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2473 let mut editor = make_editor();
2477 editor.set_text("foo".to_string());
2478 let rev_before = editor.content_revision();
2479 assert!(!editor.is_dirty());
2480 let tx = dummy_tx();
2481 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2482 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2483 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2484 }
2485 assert!(
2486 !editor.is_dirty(),
2487 "empty-stack undo/redo must not flip is_dirty"
2488 );
2489 assert_eq!(
2490 editor.content_revision(),
2491 rev_before,
2492 "empty-stack undo/redo must not bump content_revision"
2493 );
2494 }
2495
2496 #[test]
2497 fn fresh_editor_content_revision_is_nonzero() {
2498 let editor = make_editor();
2505 assert!(editor.content_revision().get() >= 1);
2506 }
2507
2508 #[test]
2509 fn mouse_down_clears_selection() {
2510 let mut editor = make_editor();
2511 editor.set_text("hello world".to_string());
2512 let ta = get_ta(&mut editor);
2513 ta.start_selection();
2514 ta.move_cursor(CursorMove::WordForward);
2515 assert!(ta.selection_range().is_some());
2516 ta.cancel_selection();
2517 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2518 tb.ta.selection_range()
2519 } else {
2520 None
2521 };
2522 assert!(editor.selection.is_none());
2523 }
2524
2525 #[test]
2526 fn ctrl_c_copies_selected_text() {
2527 let mut editor = make_editor();
2528 editor.set_text("hello world".to_string());
2529 let ta = get_ta(&mut editor);
2530 ta.move_cursor(CursorMove::Head);
2531 ta.start_selection();
2532 ta.move_cursor(CursorMove::WordForward);
2533 let range = ta.selection_range().unwrap();
2534 let ((sr, sc), (er, ec)) = range;
2535 let lines = ta.rows();
2536 let selected = if sr == er {
2537 lines[sr][sc..ec].to_string()
2538 } else {
2539 lines[sr][sc..].to_string()
2540 };
2541 assert_eq!(selected, "hello ");
2542 }
2543
2544 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2546 let ta = get_ta(editor);
2547 ta.cancel_selection();
2548 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2549 ta.start_selection();
2550 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2551 assert!(ta.selection_range().is_some());
2552 }
2553
2554 fn send_char(editor: &mut TextEditorComponent, c: char) {
2555 let tx = dummy_tx();
2556 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2557 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2558 }
2559
2560 #[test]
2561 fn typing_open_paren_with_selection_wraps_it() {
2562 let mut editor = make_editor();
2563 editor.set_text("hello world".to_string());
2564 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2566 assert_eq!(editor.get_text(), "(hello) world");
2567 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2568 }
2569
2570 #[test]
2571 fn wrap_keeps_selection_on_inner_text() {
2572 let mut editor = make_editor();
2573 editor.set_text("hello world".to_string());
2574 select_range(&mut editor, (0, 0), (0, 5));
2575 send_char(&mut editor, '(');
2576 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2578 }
2579
2580 #[test]
2581 fn chained_brackets_build_a_wikilink() {
2582 let mut editor = make_editor();
2583 editor.set_text("my note".to_string());
2584 select_range(&mut editor, (0, 0), (0, 7));
2585 send_char(&mut editor, '[');
2586 send_char(&mut editor, '[');
2587 assert_eq!(editor.get_text(), "[[my note]]");
2588 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2589 }
2590
2591 #[test]
2592 fn symmetric_chars_wrap_and_chain() {
2593 let mut editor = make_editor();
2594 editor.set_text("bold".to_string());
2595 select_range(&mut editor, (0, 0), (0, 4));
2596 send_char(&mut editor, '*');
2597 assert_eq!(editor.get_text(), "*bold*");
2598 send_char(&mut editor, '*');
2599 assert_eq!(editor.get_text(), "**bold**");
2600 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2601 }
2602
2603 #[test]
2604 fn closing_char_replaces_selection() {
2605 let mut editor = make_editor();
2606 editor.set_text("hello world".to_string());
2607 select_range(&mut editor, (0, 0), (0, 5));
2608 send_char(&mut editor, ')');
2609 assert_eq!(editor.get_text(), ") world");
2610 }
2611
2612 #[test]
2613 fn open_char_without_selection_inserts_normally() {
2614 let mut editor = make_editor();
2615 editor.set_text("hello".to_string());
2616 let ta = get_ta(&mut editor);
2617 ta.move_cursor(CursorMove::End);
2618 send_char(&mut editor, '(');
2619 assert_eq!(editor.get_text(), "hello(");
2620 }
2621
2622 #[test]
2623 fn text_action_keeps_selection_on_inner_text() {
2624 let mut editor = make_editor();
2627 editor.set_text("bold word".to_string());
2628 select_range(&mut editor, (0, 0), (0, 4));
2629 editor.apply_text_action(TextAction::Bold);
2630 assert_eq!(editor.get_text(), "**bold** word");
2631 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2632 }
2633
2634 #[test]
2635 fn bold_undo_is_one_step_back_to_original() {
2636 let mut editor = make_editor();
2641 editor.set_text("hello world".to_string());
2642 select_range(&mut editor, (0, 0), (0, 5));
2643 editor.apply_text_action(TextAction::Bold);
2644 assert_eq!(editor.get_text(), "**hello** world");
2645 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2646 assert_eq!(editor.get_text(), "hello world");
2647 assert!(
2648 !get_ta(&mut editor).undo(),
2649 "and has no second half left to take back"
2650 );
2651 }
2652
2653 #[test]
2654 fn linkable_url_accepts_supported_schemes() {
2655 assert_eq!(
2656 linkable_url("https://example.com"),
2657 Some("https://example.com")
2658 );
2659 assert_eq!(
2660 linkable_url("http://example.com/path?q=1#frag"),
2661 Some("http://example.com/path?q=1#frag"),
2662 );
2663 assert_eq!(
2664 linkable_url(" https://example.com "),
2665 Some("https://example.com")
2666 );
2667 assert_eq!(
2668 linkable_url("ftp://files.example.com/x"),
2669 Some("ftp://files.example.com/x"),
2670 );
2671 assert_eq!(
2672 linkable_url("ftps://files.example.com/x"),
2673 Some("ftps://files.example.com/x"),
2674 );
2675 assert_eq!(
2676 linkable_url("mailto:user@example.com"),
2677 Some("mailto:user@example.com"),
2678 );
2679 assert_eq!(
2680 linkable_url("mailto:user@example.com?subject=hi"),
2681 Some("mailto:user@example.com?subject=hi"),
2682 );
2683 }
2684
2685 #[test]
2686 fn linkable_url_rejects_other_schemes_and_plain_text() {
2687 assert_eq!(linkable_url("file:///etc/passwd"), None);
2688 assert_eq!(linkable_url("ssh://host"), None);
2689 assert_eq!(linkable_url("javascript:alert(1)"), None);
2690 assert_eq!(linkable_url("example.com"), None);
2691 assert_eq!(linkable_url("not a url"), None);
2692 assert_eq!(linkable_url(""), None);
2693 assert_eq!(linkable_url("https://example.com\nmore"), None);
2694 }
2695
2696 #[test]
2697 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2698 assert_eq!(
2699 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2700 Some("[click here](https://example.com)"),
2701 );
2702 }
2703
2704 #[test]
2705 fn try_build_markdown_link_trims_url_whitespace() {
2706 assert_eq!(
2707 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2708 Some("[link](https://example.com)"),
2709 );
2710 }
2711
2712 #[test]
2713 fn try_build_markdown_link_returns_none_when_no_selection() {
2714 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2715 }
2716
2717 #[test]
2718 fn try_build_markdown_link_returns_none_when_not_url() {
2719 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2720 }
2721
2722 #[test]
2723 fn try_build_markdown_link_returns_none_when_selection_empty() {
2724 assert_eq!(
2725 try_build_markdown_link("https://example.com", Some("")),
2726 None
2727 );
2728 }
2729
2730 #[test]
2731 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2732 assert_eq!(
2733 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2734 Some(r"[a\]b](https://example.com)"),
2735 );
2736 }
2737
2738 #[test]
2739 fn try_build_markdown_link_wraps_ftp_url() {
2740 assert_eq!(
2741 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2742 Some("[download](ftp://files.example.com/x)"),
2743 );
2744 }
2745
2746 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2747 ratatui::crossterm::event::KeyEvent::new(code, mods)
2748 }
2749
2750 #[test]
2752 fn search_needles_clear_on_edit() {
2753 let settings = crate::settings::AppSettings::default();
2754 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2755 ed.set_text("alpha beta".to_string());
2756 ed.set_search_needles(vec!["Alpha".to_string()]);
2757 assert_eq!(ed.search_needles, vec!["alpha"]);
2758 assert!(!ed.revs.needles_stale());
2759
2760 ed.set_text("alpha beta gamma".to_string());
2762 assert!(ed.revs.needles_stale());
2763 }
2764
2765 #[test]
2766 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2767 let mut editor = make_editor();
2768 editor.set_text("hello world".to_string());
2769 editor.open_or_advance_search();
2770 let state = editor.search.as_ref().expect("find bar opened");
2771 assert!(state.input.is_empty());
2772 assert!(matches!(state.status, SearchStatus::Empty));
2773 }
2774
2775 #[test]
2776 fn open_or_advance_search_advances_when_already_open() {
2777 let mut editor = make_editor();
2778 editor.set_text("ab ab ab".to_string());
2779 let tx = dummy_tx();
2780 editor.open_or_advance_search();
2781 editor.handle_input(
2782 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2783 &tx,
2784 );
2785 editor.handle_input(
2786 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2787 &tx,
2788 );
2789 editor.open_or_advance_search();
2791 let (_, col) = get_ta(&mut editor).cursor();
2792 assert_eq!(col, 3, "second invocation advances to next match");
2793 }
2794
2795 #[test]
2796 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2797 let mut editor = make_editor();
2798 editor.set_text("foo bar baz".to_string());
2799 let tx = dummy_tx();
2800 editor.open_or_advance_search();
2801 for ch in ['b', 'a', 'r'] {
2802 editor.handle_input(
2803 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2804 &tx,
2805 );
2806 }
2807 let state = editor.search.as_ref().unwrap();
2808 assert_eq!(state.input.value(), "bar");
2809 assert!(matches!(state.status, SearchStatus::Match));
2810 let (_, col) = get_ta(&mut editor).cursor();
2811 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2812 }
2813
2814 #[test]
2815 fn enter_in_find_bar_advances_to_next_match() {
2816 let mut editor = make_editor();
2817 editor.set_text("ab ab ab".to_string());
2818 let tx = dummy_tx();
2819 editor.open_or_advance_search();
2820 editor.handle_input(
2821 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2822 &tx,
2823 );
2824 editor.handle_input(
2825 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2826 &tx,
2827 );
2828 editor.handle_input(
2830 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
2831 &tx,
2832 );
2833 let (_, col) = get_ta(&mut editor).cursor();
2834 assert_eq!(col, 3, "Enter advances to second match");
2835 }
2836
2837 #[test]
2838 fn match_is_highlighted_as_selection_after_search() {
2839 let mut editor = make_editor();
2840 editor.set_text("foo bar baz".to_string());
2841 let tx = dummy_tx();
2842 editor.open_or_advance_search();
2843 for ch in ['b', 'a', 'r'] {
2844 editor.handle_input(
2845 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2846 &tx,
2847 );
2848 }
2849 assert_eq!(
2852 editor.search.as_ref().unwrap().current_match(),
2853 Some(((0, 4), (0, 7)))
2854 );
2855 }
2856
2857 #[test]
2858 fn no_match_clears_selection() {
2859 let mut editor = make_editor();
2860 editor.set_text("hello".to_string());
2861 let tx = dummy_tx();
2862 editor.open_or_advance_search();
2863 editor.handle_input(
2864 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2865 &tx,
2866 );
2867 assert_eq!(editor.selection, None);
2868 }
2869
2870 #[test]
2871 fn esc_in_find_bar_clears_selection_highlight() {
2872 let mut editor = make_editor();
2873 editor.set_text("foo bar".to_string());
2874 let tx = dummy_tx();
2875 editor.open_or_advance_search();
2876 editor.handle_input(
2877 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2878 &tx,
2879 );
2880 editor.handle_input(
2881 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2882 &tx,
2883 );
2884 editor.handle_input(
2885 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
2886 &tx,
2887 );
2888 assert!(
2889 editor
2890 .search
2891 .as_ref()
2892 .is_some_and(|b| b.current_match().is_some())
2893 );
2894 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2895 assert!(editor.search.is_none());
2897 assert!(editor.selection.is_none());
2898 }
2899
2900 #[test]
2901 fn esc_in_find_bar_closes_it() {
2902 let mut editor = make_editor();
2903 editor.set_text("hello".to_string());
2904 let tx = dummy_tx();
2905 editor.open_or_advance_search();
2906 assert!(editor.search.is_some());
2907 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2908 assert!(editor.search.is_none());
2909 }
2910
2911 #[test]
2912 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2913 let mut editor = make_editor();
2914 editor.set_text("hello".to_string());
2915 let tx = dummy_tx();
2916 editor.open_or_advance_search();
2917 editor.handle_input(
2918 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
2919 &tx,
2920 );
2921 assert_eq!(editor.get_text(), "hello");
2922 }
2923
2924 #[test]
2925 fn no_match_status_when_query_absent() {
2926 let mut editor = make_editor();
2927 editor.set_text("hello".to_string());
2928 let tx = dummy_tx();
2929 editor.open_or_advance_search();
2930 editor.handle_input(
2931 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2932 &tx,
2933 );
2934 let state = editor.search.as_ref().unwrap();
2935 assert!(matches!(state.status, SearchStatus::NoMatch));
2936 }
2937
2938 #[test]
2939 fn try_build_markdown_link_wraps_mailto_url() {
2940 assert_eq!(
2941 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2942 Some("[email me](mailto:user@example.com)"),
2943 );
2944 }
2945
2946 #[test]
2947 fn insert_at_cursor_appends_text() {
2948 let mut editor = make_editor();
2949 editor.set_text("hello".to_string());
2950 {
2951 let ta = get_ta(&mut editor);
2952 ta.move_cursor(CursorMove::End);
2953 }
2954 editor.insert_at_cursor(" world", &dummy_tx());
2955 assert_eq!(editor.get_text(), "hello world");
2956 }
2957
2958 #[test]
2959 fn insert_at_cursor_replaces_selection() {
2960 let mut editor = make_editor();
2961 editor.set_text("hello world".to_string());
2962 {
2963 let ta = get_ta(&mut editor);
2964 ta.move_cursor(CursorMove::Head);
2965 ta.start_selection();
2966 ta.move_cursor(CursorMove::WordForward);
2967 }
2968 editor.insert_at_cursor("HEY ", &dummy_tx());
2969 assert_eq!(editor.get_text(), "HEY world");
2970 }
2971
2972 #[test]
2973 fn paste_inserts_text_at_cursor() {
2974 let mut editor = make_editor();
2975 editor.set_text("hello".to_string());
2976 let ta = get_ta(&mut editor);
2977 ta.move_cursor(CursorMove::End);
2978 ta.insert_str(" world");
2979 assert_eq!(editor.get_text(), "hello world");
2980 }
2981
2982 #[test]
2983 fn a_selection_ending_at_column_zero_leaves_that_row_alone() {
2984 let mut editor = make_editor();
2985 editor.set_text("foo\nbar\nbaz".to_string());
2986 select_range(&mut editor, (0, 0), (2, 0));
2987 editor.indent_lines(false);
2988 assert_eq!(get_ta(&mut editor).rows(), &[" foo", " bar", "baz"]);
2989 assert_eq!(editor.selection, Some(((0, 4), (2, 0))));
2990 }
2991
2992 #[test]
2993 fn unsupported_text_action_is_noop() {
2994 let mut editor = make_editor();
2995 editor.set_text("hello".to_string());
2996 editor.apply_text_action(TextAction::Underline);
2997 assert_eq!(editor.get_text(), "hello");
2998 }
2999
3000 #[test]
3001 fn bold_action_with_no_selection_inserts_a_pair_through_the_component() {
3002 let mut editor = make_editor();
3005 editor.set_text("hello".to_string());
3006 {
3007 let ta = get_ta(&mut editor);
3008 ta.move_cursor(CursorMove::End);
3009 }
3010 editor.apply_text_action(TextAction::Bold);
3011 assert_eq!(editor.get_text(), "hello****");
3012 assert_eq!(get_ta(&mut editor).cursor(), (0, 7));
3013 assert!(editor.is_dirty(), "the insert is an edit");
3014 }
3015
3016 #[test]
3017 fn jump_to_row_moves_the_view_cursor() {
3018 let mut ed = make_editor();
3020 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
3021 ed.jump_to_row(3);
3022 assert_eq!(ed.view_snapshot().cursor.0, 3);
3023 select_range(&mut ed, (2, 1), (2, 1));
3026 ed.jump_to_row(1);
3027 assert_eq!(ed.selection, None);
3028 assert_eq!(ed.view_snapshot().cursor.0, 1);
3029 }
3030
3031 #[test]
3032 fn tab_indents_the_cursor_row_and_backtab_dedents_it() {
3033 let mut editor = make_editor();
3034 editor.set_text("foo".to_string());
3035 let tx = dummy_tx();
3036 let tab = key(KeyCode::Tab, KeyModifiers::NONE);
3037 let _ = editor.handle_input(&InputEvent::Key(tab), &tx);
3038 assert_eq!(editor.get_text(), " foo");
3039 assert!(editor.is_dirty(), "an indent is an edit");
3040 let back = key(KeyCode::BackTab, KeyModifiers::SHIFT);
3041 let _ = editor.handle_input(&InputEvent::Key(back), &tx);
3042 assert_eq!(editor.get_text(), "foo");
3043 }
3044
3045 #[test]
3046 fn enter_at_the_end_of_a_list_item_continues_it() {
3047 let mut editor = make_editor();
3048 editor.set_text("- foo".to_string());
3049 {
3050 let ta = get_ta(&mut editor);
3051 ta.move_cursor(CursorMove::End);
3052 }
3053 let tx = dummy_tx();
3054 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3055 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3056 assert_eq!(editor.get_text(), "- foo\n- ");
3057 assert!(editor.is_dirty());
3058 }
3059
3060 #[test]
3070 fn vim_normal_mode_enter_at_the_end_of_a_list_item_continues_it() {
3071 let mut editor = make_vim_editor();
3072 editor.set_text("- foo".to_string());
3073 assert_eq!(
3074 vim_mode(&editor),
3075 EditorMode::Normal,
3076 "vim starts in Normal"
3077 );
3078 {
3079 let ta = get_ta(&mut editor);
3080 ta.move_cursor(CursorMove::End);
3081 }
3082 let tx = dummy_tx();
3083 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3084 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3085 assert_eq!(editor.get_text(), "- foo\n- ");
3086 assert!(editor.is_dirty());
3087 assert_eq!(
3088 vim_mode(&editor),
3089 EditorMode::Insert,
3090 "continuing a list is also where o/O leave you: ready to type the next item"
3091 );
3092 }
3093
3094 #[test]
3099 fn vim_normal_mode_enter_on_a_plain_row_splits_it_like_insert_mode() {
3100 let mut editor = make_vim_editor();
3101 editor.set_text("foobar".to_string());
3102 {
3103 let ta = get_ta(&mut editor);
3104 ta.jump_to(0, 3);
3105 }
3106 let tx = dummy_tx();
3107 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3108 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3109 assert_eq!(editor.get_text(), "foo\nbar");
3110 assert_eq!(vim_mode(&editor), EditorMode::Insert);
3111 }
3112
3113 #[test]
3118 fn vim_normal_mode_enter_with_a_pending_operator_stays_unmapped() {
3119 let mut editor = make_vim_editor();
3120 editor.set_text("- foo".to_string());
3121 let tx = dummy_tx();
3122 let _ = editor.handle_input(
3123 &InputEvent::Key(key(KeyCode::Char('d'), KeyModifiers::NONE)),
3124 &tx,
3125 );
3126 let _ = editor.handle_input(
3127 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3128 &tx,
3129 );
3130 assert_eq!(editor.get_text(), "- foo", "d<CR> must not edit");
3131 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3132 let _ = editor.handle_input(
3134 &InputEvent::Key(key(KeyCode::Char('d'), KeyModifiers::NONE)),
3135 &tx,
3136 );
3137 assert_eq!(
3138 editor.get_text(),
3139 "",
3140 "the pending operator must have survived Enter"
3141 );
3142 }
3143
3144 #[test]
3147 fn vim_normal_mode_enter_with_a_pending_count_stays_unmapped() {
3148 let mut editor = make_vim_editor();
3149 editor.set_text("- foo".to_string());
3150 {
3151 let ta = get_ta(&mut editor);
3152 ta.move_cursor(CursorMove::End);
3153 }
3154 let tx = dummy_tx();
3155 let _ = editor.handle_input(
3156 &InputEvent::Key(key(KeyCode::Char('3'), KeyModifiers::NONE)),
3157 &tx,
3158 );
3159 let _ = editor.handle_input(
3160 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3161 &tx,
3162 );
3163 assert_eq!(editor.get_text(), "- foo", "3<CR> must not edit");
3164 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3165 }
3166
3167 #[test]
3174 fn vim_dot_repeats_the_enter_action() {
3175 let mut editor = make_vim_editor();
3176 editor.set_text("- foo".to_string());
3177 {
3178 let ta = get_ta(&mut editor);
3179 ta.move_cursor(CursorMove::End);
3180 }
3181 let tx = dummy_tx();
3182 let _ = editor.handle_input(
3183 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3184 &tx,
3185 );
3186 assert_eq!(editor.get_text(), "- foo\n- ");
3187 for c in "bar".chars() {
3188 send_char(&mut editor, c);
3189 }
3190 assert_eq!(editor.get_text(), "- foo\n- bar");
3191 let _ = editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3192 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3193 assert_eq!(
3194 get_ta(&mut editor).cursor(),
3195 (1, 4),
3196 "Esc lands one column short of the row's true end (len 5) — the exact state that broke dot-repeat"
3197 );
3198
3199 let _ = editor.handle_input(
3200 &InputEvent::Key(key(KeyCode::Char('.'), KeyModifiers::NONE)),
3201 &tx,
3202 );
3203 assert_eq!(editor.get_text(), "- foo\n- bar\n- bar");
3204 }
3205
3206 #[test]
3213 fn vim_dot_repeats_an_empty_enter_by_clearing_the_still_empty_bullet() {
3214 let mut editor = make_vim_editor();
3215 editor.set_text("- foo".to_string());
3216 {
3217 let ta = get_ta(&mut editor);
3218 ta.move_cursor(CursorMove::End);
3219 }
3220 let tx = dummy_tx();
3221 let _ = editor.handle_input(
3222 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3223 &tx,
3224 );
3225 let _ = editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3226 let _ = editor.handle_input(
3227 &InputEvent::Key(key(KeyCode::Char('.'), KeyModifiers::NONE)),
3228 &tx,
3229 );
3230 assert_eq!(editor.get_text(), "- foo\n");
3231 }
3232
3233 #[test]
3234 fn a_letter_typed_after_smart_enter_starts_its_own_undo_group() {
3235 let mut editor = make_editor();
3236 editor.set_text(String::new());
3237 for c in "- foo".chars() {
3238 send_char(&mut editor, c);
3239 }
3240 let tx = dummy_tx();
3241 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3242 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3243 send_char(&mut editor, 'b');
3244 assert_eq!(editor.get_text(), "- foo\n- b");
3245 assert!(get_ta(&mut editor).undo());
3246 assert_eq!(
3247 editor.get_text(),
3248 "- foo\n- ",
3249 "the letter after a list continuation is its own undo group"
3250 );
3251 }
3252
3253 #[test]
3254 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3255 let editor = make_editor();
3256 let hints = editor.hint_shortcuts();
3257 assert!(
3259 !hints
3260 .iter()
3261 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3262 );
3263 }
3264
3265 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3269 let ta = get_ta(editor);
3270 ta.move_cursor(CursorMove::Head);
3271 for _ in 0..col {
3272 ta.move_cursor(CursorMove::Forward);
3273 }
3274 }
3275
3276 #[test]
3277 fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
3278 let mut editor = make_editor();
3279 editor.set_text("see #rust now".to_string());
3280 place_cursor_at_col(&mut editor, 5);
3282 assert_eq!(
3283 editor.follow_target_at_cursor(),
3284 Some(FollowTarget::Label("rust".into())),
3285 );
3286 }
3287
3288 #[test]
3289 fn follow_target_at_cursor_returns_label_at_hash_char() {
3290 let mut editor = make_editor();
3291 editor.set_text("see #rust now".to_string());
3292 place_cursor_at_col(&mut editor, 4);
3294 assert_eq!(
3295 editor.follow_target_at_cursor(),
3296 Some(FollowTarget::Label("rust".into())),
3297 );
3298 }
3299
3300 #[test]
3301 fn follow_target_at_cursor_returns_none_outside_hashtag() {
3302 let mut editor = make_editor();
3303 editor.set_text("see #rust now".to_string());
3304 place_cursor_at_col(&mut editor, 0);
3306 assert_eq!(editor.follow_target_at_cursor(), None);
3307 }
3308
3309 #[test]
3310 fn follow_target_at_cursor_returns_link_for_wikilink() {
3311 let mut editor = make_editor();
3312 editor.set_text("open [[my note]] please".to_string());
3313 place_cursor_at_col(&mut editor, 7);
3315 let result = editor.follow_target_at_cursor();
3316 assert!(
3317 matches!(result, Some(FollowTarget::Link(_))),
3318 "expected Link variant, got {result:?}"
3319 );
3320 }
3321
3322 #[test]
3325 fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3326 let line = "[see docs](#section)";
3331 let mut editor = make_editor();
3332 editor.set_text(line.to_string());
3333 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3336 let result = editor.follow_target_at_cursor();
3337 assert!(
3338 matches!(result, Some(FollowTarget::Link(_))),
3339 "expected Link variant for markdown link fragment, got {result:?}"
3340 );
3341 }
3342
3343 #[test]
3344 fn vim_normal_i_then_typing_inserts_text() {
3345 let mut settings = crate::settings::AppSettings::default();
3346 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3347 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3348 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3349 editor.handle_input(
3351 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3352 &tx,
3353 );
3354 assert_eq!(editor.get_text(), "");
3355 editor.handle_input(
3357 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3358 &tx,
3359 );
3360 editor.handle_input(
3361 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3362 &tx,
3363 );
3364 assert_eq!(editor.get_text(), "x");
3365 }
3366
3367 fn open_replace_bar(
3372 editor: &mut TextEditorComponent,
3373 tx: &AppTx,
3374 pattern: &str,
3375 replacement: &str,
3376 ) {
3377 editor.open_or_advance_search();
3378 for c in pattern.chars() {
3379 editor.handle_input(
3380 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3381 tx,
3382 );
3383 }
3384 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3385 for c in replacement.chars() {
3386 editor.handle_input(
3387 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3388 tx,
3389 );
3390 }
3391 }
3392
3393 #[test]
3394 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3395 let mut editor = make_editor();
3396 let tx = dummy_tx();
3397 editor.set_text("todo".to_string());
3398 editor.open_or_advance_search();
3399 assert!(
3400 !editor.search.as_ref().unwrap().is_replacing(),
3401 "a find-only bar must not start with a replace field"
3402 );
3403
3404 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3405 let s = editor.search.as_ref().unwrap();
3406 assert!(s.is_replacing(), "Tab must reveal the replace field");
3407 assert_eq!(s.focus, BarFocus::Find);
3410
3411 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3412 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3413 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3414 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3415 }
3416
3417 #[test]
3418 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3419 let mut editor = make_editor();
3420 let tx = dummy_tx();
3421 editor.set_text("todo and todo".to_string());
3422 open_replace_bar(&mut editor, &tx, "todo", "done");
3423 assert_eq!(
3424 editor.get_text(),
3425 "todo and todo",
3426 "the preview is a view of the note, never a write to it"
3427 );
3428 }
3429
3430 #[test]
3431 fn enter_replaces_the_current_match_and_advances() {
3432 let mut editor = make_editor();
3433 let tx = dummy_tx();
3434 editor.set_text("todo and todo".to_string());
3435 open_replace_bar(&mut editor, &tx, "todo", "done");
3436 editor.handle_input(
3437 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3438 &tx,
3439 );
3440 assert_eq!(editor.get_text(), "done and todo");
3441 }
3442
3443 #[test]
3444 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3445 let mut editor = make_editor();
3451 let tx = dummy_tx();
3452 editor.set_text("e\u{301}f".to_string());
3453 open_replace_bar(&mut editor, &tx, "e", "x");
3454 editor.handle_input(
3455 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3456 &tx,
3457 );
3458 assert_eq!(
3459 editor.get_text(),
3460 "e\u{301}f",
3461 "the note is left alone rather than half-rewritten"
3462 );
3463 }
3464
3465 #[test]
3466 fn ctrl_a_replaces_every_match() {
3467 let mut editor = make_editor();
3468 let tx = dummy_tx();
3469 editor.set_text("todo and todo\nmore todo".to_string());
3470 open_replace_bar(&mut editor, &tx, "todo", "done");
3471 editor.handle_input(
3472 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3473 &tx,
3474 );
3475 assert_eq!(editor.get_text(), "done and done\nmore done");
3476 }
3477
3478 #[test]
3479 fn replace_all_keeps_the_reading_position() {
3480 let mut editor = make_editor();
3481 let tx = dummy_tx();
3482 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3483 open_replace_bar(&mut editor, &tx, "todo", "done");
3484 if let Some(ta) = editor.backend.as_textarea_mut() {
3488 ta.move_cursor(CursorMove::Jump(3, 1));
3489 }
3490 editor.handle_input(
3491 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3492 &tx,
3493 );
3494 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3495 let (row, _) = editor.cursor_pos();
3496 assert_eq!(
3497 row, 3,
3498 "replace all must not throw the cursor to the end of the note"
3499 );
3500 }
3501
3502 #[test]
3503 fn an_empty_replacement_arms_before_it_deletes() {
3504 let mut editor = make_editor();
3505 let tx = dummy_tx();
3506 editor.set_text("todo and todo".to_string());
3507 open_replace_bar(&mut editor, &tx, "todo ", "");
3508
3509 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3510 editor.handle_input(&ctrl_a, &tx);
3511 assert_eq!(
3512 editor.get_text(),
3513 "todo and todo",
3514 "the first Ctrl+A on an empty replacement must arm, not delete"
3515 );
3516 assert!(editor.search.as_ref().unwrap().armed_empty);
3517
3518 editor.handle_input(&ctrl_a, &tx);
3519 assert_eq!(editor.get_text(), "and todo");
3520 }
3521
3522 #[test]
3523 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3524 let mut editor = make_editor();
3525 let tx = dummy_tx();
3526 editor.set_text("todo".to_string());
3527 open_replace_bar(&mut editor, &tx, "todo", "");
3528 editor.handle_input(
3529 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3530 &tx,
3531 );
3532 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3533 let s = editor
3534 .search
3535 .as_ref()
3536 .expect("Esc disarms before it closes");
3537 assert!(!s.armed_empty);
3538 assert_eq!(editor.get_text(), "todo");
3539 }
3540
3541 #[test]
3542 fn one_ctrl_z_undoes_a_whole_replace_all() {
3543 let mut editor = make_editor();
3544 let tx = dummy_tx();
3545 editor.set_text("todo and todo".to_string());
3546 open_replace_bar(&mut editor, &tx, "todo", "done");
3547 editor.handle_input(
3548 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3549 &tx,
3550 );
3551 assert_eq!(editor.get_text(), "done and done");
3552
3553 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3555 editor.handle_input(
3556 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3557 &tx,
3558 );
3559 assert_eq!(
3560 editor.get_text(),
3561 "todo and todo",
3562 "a replace is two history entries and must cost ONE undo — \
3563 popping half leaves the note with a hole in it"
3564 );
3565 }
3566
3567 #[test]
3568 fn one_ctrl_z_undoes_a_single_replace_step() {
3569 let mut editor = make_editor();
3570 let tx = dummy_tx();
3571 editor.set_text("todo and todo".to_string());
3572 open_replace_bar(&mut editor, &tx, "todo", "done");
3573 editor.handle_input(
3574 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3575 &tx,
3576 );
3577 assert_eq!(editor.get_text(), "done and todo");
3578 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3579 editor.handle_input(
3580 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3581 &tx,
3582 );
3583 assert_eq!(editor.get_text(), "todo and todo");
3584 }
3585
3586 #[test]
3587 fn redo_regroups_the_replace() {
3588 let mut editor = make_editor();
3589 let tx = dummy_tx();
3590 editor.set_text("todo".to_string());
3591 open_replace_bar(&mut editor, &tx, "todo", "done");
3592 editor.handle_input(
3593 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3594 &tx,
3595 );
3596 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3597 editor.handle_input(
3598 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3599 &tx,
3600 );
3601 assert_eq!(editor.get_text(), "todo");
3602 editor.handle_input(
3603 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3604 &tx,
3605 );
3606 assert_eq!(
3607 editor.get_text(),
3608 "done",
3609 "one redo must restore the whole replace"
3610 );
3611 }
3612
3613 #[test]
3614 fn smartcase_drives_both_the_count_and_the_replace() {
3615 let mut editor = make_editor();
3616 let tx = dummy_tx();
3617 editor.set_text("todo Todo TODO".to_string());
3618 open_replace_bar(&mut editor, &tx, "todo", "x");
3619 assert_eq!(
3620 editor.search.as_ref().unwrap().match_count,
3621 3,
3622 "an all-lowercase pattern matches any case"
3623 );
3624 editor.handle_input(
3625 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3626 &tx,
3627 );
3628 assert_eq!(editor.get_text(), "x x x");
3629 }
3630
3631 #[test]
3632 fn an_uppercase_pattern_is_case_sensitive() {
3633 let mut editor = make_editor();
3634 let tx = dummy_tx();
3635 editor.set_text("todo Todo TODO".to_string());
3636 open_replace_bar(&mut editor, &tx, "Todo", "x");
3637 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3638 editor.handle_input(
3639 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3640 &tx,
3641 );
3642 assert_eq!(editor.get_text(), "todo x TODO");
3643 }
3644
3645 #[test]
3646 fn the_preview_substitutes_lines_without_writing_them() {
3647 let mut editor = make_editor();
3648 let tx = dummy_tx();
3649 editor.set_text("todo and todo".to_string());
3650 open_replace_bar(&mut editor, &tx, "todo", "done");
3651 let preview = editor.replace_preview().expect("a preview must be built");
3652 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3653 assert_eq!(preview.spans.len(), 2);
3654 assert!(
3655 preview.spans.iter().any(|s| s.is_current),
3656 "the match under the cursor must be flagged so Enter's target is visible"
3657 );
3658 assert_eq!(
3659 editor.get_text(),
3660 "todo and todo",
3661 "building a preview must never mutate the buffer"
3662 );
3663 }
3664
3665 #[test]
3670 fn a_deletion_preview_still_marks_the_current_match() {
3671 let mut editor = make_editor();
3672 let tx = dummy_tx();
3673 editor.set_text("todo and todo".to_string());
3674 open_replace_bar(&mut editor, &tx, "todo", "");
3675 let preview = editor.replace_preview().expect("a preview must be built");
3676 assert_eq!(preview.lines, vec![" and ".to_string()]);
3677 let current = preview
3678 .spans
3679 .iter()
3680 .find(|s| s.is_current)
3681 .expect("the current match must stay flagged when it previews as nothing");
3682 assert_eq!(
3683 current.start, current.end,
3684 "an empty replacement previews as a zero-width span — the renderer \
3685 widens it to a caret cell so the marker cannot vanish"
3686 );
3687 }
3688
3689 #[test]
3694 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3695 let mut editor = make_editor();
3696 let tx = dummy_tx();
3697 editor.set_text("alpha beta\nxy".to_string());
3698 open_replace_bar(&mut editor, &tx, "beta", "Z");
3699 editor.selection = Some(((0, 6), (1, 1)));
3701 editor.handle_input(
3702 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3703 &tx,
3704 );
3705 assert_eq!(editor.get_text(), "alpha Z\nxy");
3706 }
3707
3708 #[test]
3713 fn deleting_a_match_marks_the_note_dirty() {
3714 let mut editor = make_editor();
3715 let tx = dummy_tx();
3716 editor.set_text("todo and todo".to_string());
3717 editor.mark_saved("todo and todo".to_string());
3718 assert!(!editor.is_dirty());
3719
3720 open_replace_bar(&mut editor, &tx, "todo ", "");
3721 editor.handle_input(
3722 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3723 &tx,
3724 );
3725 assert_eq!(editor.get_text(), "and todo");
3726 assert!(
3727 editor.is_dirty(),
3728 "a deletion is an edit — if the revision does not move, autosave \
3729 never writes it and the change is silently lost"
3730 );
3731 }
3732
3733 #[test]
3735 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
3736 let mut editor = make_editor();
3737 let tx = dummy_tx();
3738 editor.set_text("todo".to_string());
3739 editor.mark_saved("todo".to_string());
3740 open_replace_bar(&mut editor, &tx, "todo", "");
3741 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3742 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
3745 assert!(editor.is_dirty());
3746
3747 editor.handle_input(
3748 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3749 &tx,
3750 );
3751 assert_eq!(editor.get_text(), "todo");
3752 }
3753
3754 #[test]
3758 fn ctrl_z_works_without_closing_the_bar_first() {
3759 let mut editor = make_editor();
3760 let tx = dummy_tx();
3761 editor.set_text("todo and todo".to_string());
3762 open_replace_bar(&mut editor, &tx, "todo", "done");
3763 editor.handle_input(
3764 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3765 &tx,
3766 );
3767 assert_eq!(editor.get_text(), "done and done");
3768 editor.handle_input(
3769 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3770 &tx,
3771 );
3772 assert_eq!(editor.get_text(), "todo and todo");
3773 assert!(editor.search.is_some(), "undo must not close the bar");
3774 }
3775
3776 #[test]
3780 fn a_zero_width_match_does_not_over_claim_history_entries() {
3781 let mut editor = make_editor();
3782 let tx = dummy_tx();
3783 editor.set_text("ab".to_string());
3784 open_replace_bar(&mut editor, &tx, r"\b", "|");
3785 editor.handle_input(
3786 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3787 &tx,
3788 );
3789 assert_eq!(editor.get_text(), "|ab");
3790 editor.handle_input(
3791 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3792 &tx,
3793 );
3794 assert_eq!(
3795 editor.get_text(),
3796 "ab",
3797 "one undo must land exactly on the pre-replace text, not past it"
3798 );
3799 }
3800
3801 #[test]
3805 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
3806 let mut editor = make_editor();
3807 let tx = dummy_tx();
3808 editor.set_text("todo".to_string());
3809 open_replace_bar(&mut editor, &tx, "todo", "");
3810 editor.handle_input(
3811 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3812 &tx,
3813 );
3814 assert!(editor.search.as_ref().unwrap().armed_empty);
3815
3816 editor.set_text("todo elsewhere".to_string());
3817 assert!(editor.search.is_none(), "the bar belonged to the old note");
3818 assert!(
3821 !editor.backend.as_textarea_mut().unwrap().undo(),
3822 "the new note's history has nothing to undo"
3823 );
3824 }
3825
3826 #[test]
3832 fn concealed_markdown_still_highlights_what_it_counts() {
3833 let mut editor = make_editor();
3834 let tx = dummy_tx();
3835 editor.set_text("# Heading\n[[note]]".to_string());
3836 editor.open_or_advance_search();
3837 for c in r"\[\[".chars() {
3838 editor.handle_input(
3839 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3840 &tx,
3841 );
3842 }
3843 let state = editor.search.as_ref().unwrap();
3844 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
3845 let spans = state
3846 .pattern
3847 .as_ref()
3848 .unwrap()
3849 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
3850 assert_eq!(
3851 spans,
3852 vec![(1, 0, 2)],
3853 "and it must be reported as a paintable span, not silently dropped \
3854 because the rendered row conceals it"
3855 );
3856 }
3857
3858 #[test]
3863 fn paste_goes_into_the_focused_bar_field() {
3864 let mut editor = make_editor();
3865 let tx = dummy_tx();
3866 editor.set_text("todo".to_string());
3867 open_replace_bar(&mut editor, &tx, "todo", "");
3868 editor.paste_text("done", &tx);
3869 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
3870 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
3871 }
3872
3873 #[test]
3874 fn a_multiline_paste_collapses_to_its_first_line() {
3875 let mut editor = make_editor();
3876 let tx = dummy_tx();
3877 editor.set_text("x".to_string());
3878 editor.open_or_advance_search();
3879 editor.paste_text("first\nsecond", &tx);
3880 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
3881 }
3882
3883 #[test]
3887 fn the_bar_is_never_an_invisible_modal() {
3888 use ratatui::Terminal;
3889 use ratatui::backend::TestBackend;
3890 let mut editor = make_editor();
3891 editor.set_text("todo".to_string());
3892 let theme = Theme::default();
3893 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
3894 let area = Rect::new(0, 0, 40, 1);
3895 editor.open_or_advance_search();
3896 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3897 let row: String = (0..40)
3898 .filter_map(|x| {
3899 term.backend()
3900 .buffer()
3901 .cell(ratatui::layout::Position::new(x, 0))
3902 .map(|c| c.symbol().to_string())
3903 })
3904 .collect();
3905 assert!(
3906 row.contains("Find:"),
3907 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
3908 );
3909 }
3910
3911 #[test]
3923 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
3924 use ratatui::Terminal;
3925 use ratatui::backend::TestBackend;
3926 let mut editor = make_editor();
3927 let tx = dummy_tx();
3928 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
3929 lines[0] = "todo".to_string();
3930 lines[398] = "todo".to_string();
3931 editor.set_text(lines.join("\n"));
3932 let theme = Theme::default();
3933 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
3934 let area = Rect::new(0, 0, 20, 8);
3935 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3936
3937 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
3938 if let Some(ta) = editor.backend.as_textarea_mut() {
3941 ta.move_cursor(CursorMove::Jump(398, 0));
3942 }
3943 editor.handle_input(
3944 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3945 &tx,
3946 );
3947 if let Some(ta) = editor.backend.as_textarea_mut() {
3950 ta.move_cursor(CursorMove::Jump(1, 0));
3951 }
3952 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3953 let row0: String = (0..20)
3954 .filter_map(|x| {
3955 term.backend()
3956 .buffer()
3957 .cell(ratatui::layout::Position::new(x, 0))
3958 .map(|c| c.symbol().to_string())
3959 })
3960 .collect::<String>()
3961 .trim_end()
3962 .to_string();
3963 assert_eq!(
3964 row0, "x",
3965 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
3966 kept the parse of the text that was there before the replace"
3967 );
3968 }
3969
3970 #[test]
3974 fn indenting_a_block_undoes_in_one_step() {
3975 let mut editor = make_editor();
3976 let tx = dummy_tx();
3977 editor.set_text("a\nb\nc".to_string());
3978 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3979 get_ta(&mut editor).start_selection();
3980 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
3981 editor.indent_lines(false);
3982 assert_eq!(editor.get_text(), " a\n b\n c");
3983
3984 editor.handle_input(
3985 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3986 &tx,
3987 );
3988 assert_eq!(
3989 editor.get_text(),
3990 "a\nb\nc",
3991 "one undo must revert the whole block, not just the last line"
3992 );
3993 }
3994
3995 #[test]
3997 fn pasting_over_a_selection_undoes_in_one_step() {
3998 let mut editor = make_editor();
3999 let tx = dummy_tx();
4000 editor.set_text("hello world".to_string());
4001 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4002 get_ta(&mut editor).start_selection();
4003 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
4004 editor.paste_text("goodbye", &tx);
4005 assert_eq!(editor.get_text(), "goodbye world");
4006
4007 editor.handle_input(
4008 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4009 &tx,
4010 );
4011 assert_eq!(editor.get_text(), "hello world");
4012 }
4013
4014 #[test]
4018 fn closing_the_bar_clears_a_stale_selection() {
4019 let mut editor = make_editor();
4020 let tx = dummy_tx();
4021 editor.set_text("alpha beta".to_string());
4022 editor.selection = Some(((0, 0), (0, 5)));
4023 editor.open_or_advance_search();
4024 for c in "beta".chars() {
4025 editor.handle_input(
4026 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4027 &tx,
4028 );
4029 }
4030 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4031 assert!(editor.search.is_none());
4032 assert_eq!(
4033 editor.selection, None,
4034 "a selection from before the search must not outlive the bar"
4035 );
4036 }
4037
4038 #[test]
4041 fn undo_inside_the_bar_rederives_the_current_match() {
4042 let mut editor = make_editor();
4043 let tx = dummy_tx();
4044 editor.set_text("foo foo".to_string());
4045 open_replace_bar(&mut editor, &tx, "foo", "xy");
4046 editor.handle_input(
4047 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4048 &tx,
4049 );
4050 assert_eq!(editor.get_text(), "xy foo");
4051 editor.handle_input(
4052 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4053 &tx,
4054 );
4055 assert_eq!(editor.get_text(), "foo foo");
4056 let current = editor.search.as_ref().unwrap().current_match();
4057 if let Some(((row, start), (_, end))) = current {
4058 let line = &editor.get_text()[..];
4059 let text: String = line
4060 .lines()
4061 .nth(row)
4062 .unwrap()
4063 .chars()
4064 .skip(start)
4065 .take(end - start)
4066 .collect();
4067 assert_eq!(
4068 text, "foo",
4069 "the highlight must sit on a real match, got {text:?}"
4070 );
4071 }
4072 }
4073
4074 #[test]
4078 fn vim_n_highlights_the_match_it_lands_on() {
4079 let mut editor = make_vim_editor();
4080 let tx = dummy_tx();
4081 editor.set_text("lo xx lo".to_string());
4082 editor.open_or_advance_search();
4083 for c in "lo".chars() {
4084 editor.handle_input(
4085 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4086 &tx,
4087 );
4088 }
4089 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4090 editor.handle_input(
4091 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4092 &tx,
4093 );
4094 assert_eq!(
4095 editor.selection,
4096 Some(((0, 6), (0, 8))),
4097 "`n` must paint the match it jumped to"
4098 );
4099 }
4100
4101 #[test]
4106 fn closing_the_bar_cannot_leave_an_invisible_selection() {
4107 let mut editor = make_editor();
4108 let tx = dummy_tx();
4109 editor.set_text("foo bar baz".to_string());
4110 get_ta(&mut editor).select_all();
4112 editor.open_or_advance_search();
4113 for c in "bar".chars() {
4114 editor.handle_input(
4115 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4116 &tx,
4117 );
4118 }
4119 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4120 editor.handle_input(
4121 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
4122 &tx,
4123 );
4124 assert!(
4125 editor.get_text().contains("foo"),
4126 "typing after the bar closed must not eat unhighlighted text, got {:?}",
4127 editor.get_text()
4128 );
4129 }
4130
4131 #[test]
4134 fn vim_visual_indent_undoes_in_one_step() {
4135 let mut editor = make_vim_editor();
4136 let tx = dummy_tx();
4137 editor.set_text("a\nb\nc".to_string());
4138 for c in ['V', 'j', 'j', '>'] {
4139 editor.handle_input(
4140 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4141 &tx,
4142 );
4143 }
4144 let indented = editor.get_text();
4145 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
4146 editor.handle_input(
4147 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4148 &tx,
4149 );
4150 assert_eq!(
4151 editor.get_text(),
4152 "a\nb\nc",
4153 "one `u` must revert the whole indent, not one row"
4154 );
4155 }
4156
4157 #[test]
4160 fn vim_charwise_visual_indent_shifts_the_touched_rows() {
4161 let mut editor = make_vim_editor();
4162 let tx = dummy_tx();
4163 editor.set_text("one\ntwo\nthree".to_string());
4164 for c in ['l', 'v', 'j'] {
4165 editor.handle_input(
4166 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4167 &tx,
4168 );
4169 }
4170 editor.handle_input(
4172 &InputEvent::Key(key(KeyCode::Char('>'), KeyModifiers::SHIFT)),
4173 &tx,
4174 );
4175 assert_eq!(editor.get_text(), " one\n two\nthree");
4176 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4177 assert_eq!(editor.selection, None);
4178 }
4179
4180 #[test]
4183 fn vim_gv_does_not_cross_notes() {
4184 let mut editor = make_vim_editor();
4185 let tx = dummy_tx();
4186 editor.set_text("a\nb\nc".to_string());
4187 for c in ['V', 'j'] {
4188 editor.handle_input(
4189 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4190 &tx,
4191 );
4192 }
4193 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4194 editor.set_text("x\ny\nz".to_string());
4195 for c in ['g', 'v'] {
4196 editor.handle_input(
4197 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4198 &tx,
4199 );
4200 }
4201 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4202 assert_eq!(editor.selection, None);
4203 }
4204
4205 fn press(editor: &mut TextEditorComponent, keys: &str) {
4206 let tx = dummy_tx();
4207 for c in keys.chars() {
4208 editor.handle_input(
4209 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4210 &tx,
4211 );
4212 }
4213 }
4214
4215 fn note(name: &str) -> kimun_core::nfs::VaultPath {
4216 kimun_core::nfs::VaultPath::note_path_from(name)
4217 }
4218
4219 #[test]
4224 fn a_note_with_the_same_text_does_not_inherit_undo_history() {
4225 let mut editor = make_vim_editor();
4226 editor.open_note(note("alpha"), "one".to_string());
4227 press(&mut editor, "x");
4228 assert_eq!(editor.get_text(), "ne");
4229 editor.open_note(note("beta"), "ne".to_string());
4230 press(&mut editor, "u");
4231 assert_eq!(editor.get_text(), "ne", "beta has nothing to undo");
4232 }
4233
4234 #[test]
4237 fn empty_notes_do_not_share_history() {
4238 let mut editor = make_vim_editor();
4239 let tx = dummy_tx();
4240 editor.open_note(note("alpha"), String::new());
4241 press(&mut editor, "ix");
4242 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4243 press(&mut editor, "u");
4244 assert_eq!(editor.get_text(), "");
4245 editor.open_note(note("beta"), String::new());
4246 editor.handle_input(
4247 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::CONTROL)),
4248 &tx,
4249 );
4250 assert_eq!(editor.get_text(), "", "beta has nothing to redo");
4251 }
4252
4253 #[test]
4255 fn a_note_with_the_same_text_does_not_inherit_the_selection() {
4256 let mut editor = make_vim_editor();
4257 let tx = dummy_tx();
4258 editor.open_note(note("alpha"), "a\nb\nc".to_string());
4259 press(&mut editor, "Vj");
4260 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4261 press(&mut editor, "Vj");
4262 editor.open_note(note("beta"), "a\nb\nc".to_string());
4263 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4264 assert_eq!(editor.selection, None);
4265 press(&mut editor, "gv");
4266 assert_eq!(
4267 vim_mode(&editor),
4268 EditorMode::Normal,
4269 "no selection to reselect"
4270 );
4271 }
4272
4273 fn top_row(term: &ratatui::Terminal<ratatui::backend::TestBackend>, width: u16) -> String {
4275 (0..width)
4276 .filter_map(|x| term.backend().buffer().cell((x, 0)).map(|c| c.symbol()))
4277 .collect::<String>()
4278 .trim_end()
4279 .to_string()
4280 }
4281
4282 #[test]
4285 fn another_note_opens_scrolled_to_the_top() {
4286 use ratatui::Terminal;
4287 use ratatui::backend::TestBackend;
4288 let mut editor = make_vim_editor();
4289 let theme = Theme::default();
4290 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
4291 let area = Rect::new(0, 0, 20, 8);
4292 let body: String = (0..400).map(|i| format!("line {i}\n")).collect();
4293 editor.open_note(note("alpha"), body.clone());
4294 press(&mut editor, "300G");
4295 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4296 assert_ne!(top_row(&term, 20), "line 0", "alpha is scrolled down");
4297
4298 editor.open_note(note("beta"), body);
4299 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4300 assert_eq!(top_row(&term, 20), "line 0");
4301 }
4302
4303 #[test]
4306 fn reopening_the_same_note_keeps_its_history() {
4307 let mut editor = make_vim_editor();
4308 editor.open_note(note("alpha"), "one".to_string());
4309 press(&mut editor, "x");
4310 editor.open_note(note("alpha"), "ne".to_string());
4311 press(&mut editor, "u");
4312 assert_eq!(editor.get_text(), "one");
4313 }
4314
4315 #[test]
4318 fn renaming_the_open_note_keeps_its_history() {
4319 let mut editor = make_vim_editor();
4320 editor.open_note(note("alpha"), "one".to_string());
4321 press(&mut editor, "x");
4322 editor.renamed_to(note("gamma"));
4323 editor.set_text("ne".to_string());
4324 editor.open_note(note("gamma"), "ne".to_string());
4325 press(&mut editor, "u");
4326 assert_eq!(editor.get_text(), "one");
4327 }
4328
4329 #[test]
4332 fn vim_gv_after_external_paste_lands_where_the_selection_was() {
4333 let mut editor = make_vim_editor();
4334 let tx = dummy_tx();
4335 editor.set_text("a\nb\nc".to_string());
4336 for c in ['j', 'V', 'j'] {
4337 editor.handle_input(
4338 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4339 &tx,
4340 );
4341 }
4342 editor.take_selection_for_external_paste();
4343 for c in ['g', 'v'] {
4344 editor.handle_input(
4345 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4346 &tx,
4347 );
4348 }
4349 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4350 let ta = get_ta(&mut editor);
4351 assert_eq!(ta.cursor().0, 1);
4352 }
4353
4354 #[test]
4357 fn vim_gv_repaints_the_last_selection() {
4358 let mut editor = make_vim_editor();
4359 let tx = dummy_tx();
4360 editor.set_text("a\nb\nc".to_string());
4361 for c in ['V', 'j', '>', 'g', 'v'] {
4362 editor.handle_input(
4363 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4364 &tx,
4365 );
4366 }
4367 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4368 assert!(editor.selection.is_some(), "gv must paint the highlight");
4369 }
4370
4371 #[test]
4375 fn vim_n_cannot_leave_an_invisible_selection() {
4376 let mut editor = make_vim_editor();
4377 let tx = dummy_tx();
4378 editor.set_text("foo bar foo".to_string());
4379 editor.open_or_advance_search();
4380 for c in "foo".chars() {
4381 editor.handle_input(
4382 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4383 &tx,
4384 );
4385 }
4386 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4387 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4389 get_ta(&mut editor).start_selection();
4390 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
4391 editor.handle_input(
4392 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4393 &tx,
4394 );
4395 for c in ['i', 'X'] {
4396 editor.handle_input(
4397 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4398 &tx,
4399 );
4400 }
4401 assert!(
4402 editor.get_text().contains("bar"),
4403 "typing after `n` must not eat unhighlighted text, got {:?}",
4404 editor.get_text()
4405 );
4406 }
4407
4408 #[test]
4413 fn overlays_paint_where_the_post_pass_used_to() {
4414 use ratatui::Terminal;
4415 use ratatui::backend::TestBackend;
4416 use ratatui::layout::Position;
4417 let mut editor = make_editor();
4418 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
4419 editor.set_search_needles(vec!["needle".to_string()]);
4420 let theme = Theme::default();
4421 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
4422 let area = Rect::new(0, 0, 40, 6);
4423 term.draw(|f| editor.render(f, area, &theme, false))
4424 .unwrap();
4425 let buf = term.backend().buffer();
4426
4427 let row: String = (0..40)
4428 .filter_map(|x| {
4429 buf.cell(Position::new(x, 0))
4430 .map(|c| c.symbol().to_string())
4431 })
4432 .collect();
4433 let at = row.find("needle").expect("needle is on screen");
4434 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
4435 assert_eq!(
4436 cell.fg,
4437 theme.color_search_match.to_ratatui(),
4438 "the needle must still be emphasised"
4439 );
4440
4441 let struck = |y: u16| {
4443 (0..40).any(|x| {
4444 buf.cell(Position::new(x, y)).is_some_and(|c| {
4445 c.style()
4446 .add_modifier
4447 .contains(ratatui::style::Modifier::CROSSED_OUT)
4448 })
4449 })
4450 };
4451 assert!(struck(1), "a done task strikes its text");
4452 assert!(!struck(2), "an open task does not");
4453
4454 for y in [1u16, 2] {
4456 assert!(
4457 (0..40).any(|x| buf
4458 .cell(Position::new(x, y))
4459 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
4460 "row {y} must have an accent-coloured checkbox"
4461 );
4462 }
4463 }
4464
4465 #[test]
4466 fn no_preview_without_a_replace_field() {
4467 let mut editor = make_editor();
4468 let tx = dummy_tx();
4469 editor.set_text("todo".to_string());
4470 editor.open_or_advance_search();
4471 for c in "todo".chars() {
4472 editor.handle_input(
4473 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4474 &tx,
4475 );
4476 }
4477 assert!(
4478 editor.replace_preview().is_none(),
4479 "a find-only bar previews nothing"
4480 );
4481 }
4482
4483 #[test]
4484 fn capture_expansion_is_gated_on_the_pattern_capturing() {
4485 let mut editor = make_editor();
4486 let tx = dummy_tx();
4487 editor.set_text("cost".to_string());
4489 open_replace_bar(&mut editor, &tx, "cost", "$1");
4490 editor.handle_input(
4491 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4492 &tx,
4493 );
4494 assert_eq!(editor.get_text(), "$1");
4495 }
4496
4497 #[test]
4498 fn the_bar_reserves_two_rows_only_while_replacing() {
4499 use ratatui::Terminal;
4500 use ratatui::backend::TestBackend;
4501 let mut editor = make_editor();
4502 let tx = dummy_tx();
4503 editor.set_text("todo".to_string());
4504 let theme = Theme::default();
4505 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4506 let area = Rect::new(0, 0, 40, 10);
4507
4508 editor.open_or_advance_search();
4509 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4510 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4511
4512 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4513 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4514 assert_eq!(
4515 editor.rect.height, 8,
4516 "the replace field takes a second row"
4517 );
4518 }
4519
4520 #[test]
4525 fn guu_really_does_undo_in_one_step() {
4526 let mut editor = make_vim_editor();
4527 let tx = dummy_tx();
4528 editor.set_text("Mixed Case Line".to_string());
4529 for c in "guu".chars() {
4530 editor.handle_input(
4531 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4532 &tx,
4533 );
4534 }
4535 assert_eq!(editor.get_text(), "mixed case line");
4536 editor.handle_input(
4537 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4538 &tx,
4539 );
4540 assert_eq!(editor.get_text(), "Mixed Case Line");
4541 }
4542
4543 #[test]
4548 fn vim_u_undoes_a_whole_replace() {
4549 let mut editor = make_vim_editor();
4550 let tx = dummy_tx();
4551 editor.set_text("todo and todo".to_string());
4552 open_replace_bar(&mut editor, &tx, "todo", "done");
4553 editor.handle_input(
4554 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4555 &tx,
4556 );
4557 assert_eq!(editor.get_text(), "done and done");
4558
4559 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4561 editor.handle_input(
4562 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4563 &tx,
4564 );
4565 assert_eq!(editor.get_text(), "todo and todo");
4566 }
4567
4568 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4571 use ratatui::crossterm::event::KeyEvent;
4572 for c in text.chars() {
4573 let code = if c == '\n' {
4574 KeyCode::Enter
4575 } else {
4576 KeyCode::Char(c)
4577 };
4578 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4579 }
4580 }
4581
4582 #[test]
4583 fn undo_takes_back_a_word_not_a_letter() {
4584 let mut editor = make_editor();
4587 let tx = dummy_tx();
4588 editor.set_text(String::new());
4589 type_out(&mut editor, &tx, "hello world");
4590 assert_eq!(editor.get_text(), "hello world");
4591
4592 assert!(get_ta(&mut editor).undo());
4593 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4594 assert!(get_ta(&mut editor).undo());
4595 assert_eq!(editor.get_text(), "", "and so does the first");
4596 }
4597
4598 #[test]
4599 fn a_cursor_move_separates_two_runs() {
4600 let mut editor = make_editor();
4601 let tx = dummy_tx();
4602 editor.set_text(String::new());
4603 type_out(&mut editor, &tx, "ab");
4604 arrow(&mut editor, &tx, KeyCode::Home);
4605 type_out(&mut editor, &tx, "cd");
4606 assert_eq!(editor.get_text(), "cdab");
4607
4608 assert!(get_ta(&mut editor).undo());
4609 assert_eq!(
4610 editor.get_text(),
4611 "ab",
4612 "only what was typed after the move comes back off"
4613 );
4614 }
4615
4616 #[test]
4617 fn backspacing_to_fix_a_typo_is_its_own_action() {
4618 use ratatui::crossterm::event::KeyEvent;
4619 let mut editor = make_editor();
4620 let tx = dummy_tx();
4621 editor.set_text(String::new());
4622 type_out(&mut editor, &tx, "helllo");
4623 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4624 assert_eq!(editor.get_text(), "helll");
4625
4626 assert!(get_ta(&mut editor).undo());
4627 assert_eq!(
4628 editor.get_text(),
4629 "helllo",
4630 "the delete undoes on its own, without taking the typing with it"
4631 );
4632 }
4633
4634 #[test]
4635 fn an_undo_between_two_runs_separates_them() {
4636 let mut editor = make_editor();
4639 let tx = dummy_tx();
4640 editor.set_text(String::new());
4641 type_out(&mut editor, &tx, "ab");
4642 assert!(get_ta(&mut editor).undo());
4643 assert_eq!(editor.get_text(), "");
4644 type_out(&mut editor, &tx, "cd");
4645 assert_eq!(editor.get_text(), "cd");
4646 assert!(get_ta(&mut editor).undo());
4647 assert_eq!(
4648 editor.get_text(),
4649 "",
4650 "the second run is its own group, not an extension of an undone one"
4651 );
4652 }
4653
4654 #[test]
4655 fn a_save_closes_the_open_group() {
4656 let mut editor = make_editor();
4660 let tx = dummy_tx();
4661 type_out(&mut editor, &tx, "abc");
4662 let saved = editor.get_text();
4663 editor.mark_saved(saved);
4664 type_out(&mut editor, &tx, "def");
4667
4668 assert!(get_ta(&mut editor).undo());
4669 assert_eq!(
4670 editor.get_text(),
4671 "abc",
4672 "one undo lands on what was saved, not before it"
4673 );
4674 }
4675
4676 #[test]
4677 fn a_stale_save_completion_does_not_close_the_group() {
4678 let mut editor = make_editor();
4682 let tx = dummy_tx();
4683 type_out(&mut editor, &tx, "abc");
4684 let stale = NonZeroU64::new(1).expect("nonzero");
4685 editor.mark_saved_at_revision(stale);
4686 type_out(&mut editor, &tx, "def");
4687
4688 assert!(get_ta(&mut editor).undo());
4689 assert_eq!(
4690 editor.get_text(),
4691 "",
4692 "the run carried on across a completion that marked nothing"
4693 );
4694 }
4695
4696 #[test]
4697 fn a_second_vim_insert_session_is_its_own_group() {
4698 use ratatui::crossterm::event::KeyEvent;
4702 let mut editor = make_vim_editor();
4703 let tx = dummy_tx();
4704 editor.set_text(String::new());
4705 let press = |editor: &mut TextEditorComponent, code| {
4706 let _ = editor.handle_input(
4707 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4708 &tx,
4709 );
4710 };
4711 press(&mut editor, KeyCode::Char('i'));
4712 for c in "one".chars() {
4713 press(&mut editor, KeyCode::Char(c));
4714 }
4715 press(&mut editor, KeyCode::Esc);
4716 press(&mut editor, KeyCode::Char('i'));
4717 for c in "two".chars() {
4718 press(&mut editor, KeyCode::Char(c));
4719 }
4720 press(&mut editor, KeyCode::Esc);
4721 assert_eq!(editor.get_text(), "ontwoe");
4724
4725 assert!(get_ta(&mut editor).undo());
4726 assert_eq!(
4727 editor.get_text(),
4728 "one",
4729 "`u` takes back the second session only"
4730 );
4731 }
4732
4733 #[test]
4734 fn a_vim_insert_session_undoes_whole() {
4735 use ratatui::crossterm::event::KeyEvent;
4736 let mut editor = make_vim_editor();
4737 let tx = dummy_tx();
4738 editor.set_text(String::new());
4739 let press = |editor: &mut TextEditorComponent, code| {
4741 let _ = editor.handle_input(
4742 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4743 &tx,
4744 );
4745 };
4746 press(&mut editor, KeyCode::Char('i'));
4747 for c in "hello world".chars() {
4748 press(&mut editor, KeyCode::Char(c));
4749 }
4750 press(&mut editor, KeyCode::Esc);
4751 assert_eq!(editor.get_text(), "hello world");
4752
4753 assert!(get_ta(&mut editor).undo());
4754 assert_eq!(
4755 editor.get_text(),
4756 "",
4757 "vim's `u` takes back the whole session, word boundaries included"
4758 );
4759 }
4760
4761 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4765 use ratatui::Terminal;
4766 use ratatui::backend::TestBackend;
4767 let theme = Theme::default();
4768 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4769 let area = Rect::new(0, 0, width, height);
4770 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4771 }
4772
4773 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4774 use ratatui::crossterm::event::KeyEvent;
4775 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4776 }
4777
4778 #[test]
4779 fn down_moves_one_drawn_line_not_one_row() {
4780 let mut editor = make_editor();
4783 let tx = dummy_tx();
4784 editor.set_text(
4785 "aaaa bbbb cccc dddd
4786second row"
4787 .to_string(),
4788 );
4789 lay_out(&mut editor, 6, 10);
4790
4791 get_ta(&mut editor).jump_to(0, 0);
4792 arrow(&mut editor, &tx, KeyCode::Down);
4793 assert_eq!(
4794 get_ta(&mut editor).cursor(),
4795 (0, 5),
4796 "still inside the first row, on its second drawn line"
4797 );
4798 arrow(&mut editor, &tx, KeyCode::Down);
4799 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4800 arrow(&mut editor, &tx, KeyCode::Down);
4801 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4802 arrow(&mut editor, &tx, KeyCode::Down);
4803 assert_eq!(
4804 get_ta(&mut editor).cursor().0,
4805 1,
4806 "and only the fourth press reaches the next row"
4807 );
4808 }
4809
4810 #[test]
4811 fn up_and_down_are_symmetric_across_a_wrap() {
4812 let mut editor = make_editor();
4813 let tx = dummy_tx();
4814 editor.set_text("aaaa bbbb cccc".to_string());
4815 lay_out(&mut editor, 6, 10);
4816
4817 get_ta(&mut editor).jump_to(0, 0);
4818 arrow(&mut editor, &tx, KeyCode::Down);
4819 let middle = get_ta(&mut editor).cursor();
4820 arrow(&mut editor, &tx, KeyCode::Up);
4821 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4822 assert_eq!(middle, (0, 5));
4823 }
4824
4825 #[test]
4826 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4827 let mut editor = make_editor();
4832 let tx = dummy_tx();
4833 editor.set_text("abcd\nefgh".to_string());
4834 lay_out(&mut editor, 20, 10);
4835
4836 get_ta(&mut editor).jump_to(0, 4);
4837 for _ in 0..3 {
4838 get_ta(&mut editor).delete_char();
4839 }
4840 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4841
4842 arrow(&mut editor, &tx, KeyCode::Down);
4844 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4845 }
4846
4847 #[test]
4848 fn an_action_between_arrows_forgets_the_goal_cell() {
4849 let mut editor = make_editor();
4855 let tx = dummy_tx();
4856 editor.set_text(
4857 "aaaaaaaa
4858bb
4859cccccccc"
4860 .to_string(),
4861 );
4862 lay_out(&mut editor, 20, 10);
4863
4864 get_ta(&mut editor).jump_to(0, 7);
4865 arrow(&mut editor, &tx, KeyCode::Down);
4866 assert_eq!(
4867 get_ta(&mut editor).cursor(),
4868 (1, 2),
4869 "clamped to the short row"
4870 );
4871
4872 let saved = editor.get_text();
4873 editor.mark_saved(saved);
4874
4875 arrow(&mut editor, &tx, KeyCode::Down);
4876 assert_eq!(
4877 get_ta(&mut editor).cursor(),
4878 (2, 2),
4879 "the goal was forgotten, so the third row keeps the clamped column"
4880 );
4881 }
4882
4883 #[test]
4884 fn a_run_of_arrows_keeps_its_goal_cell() {
4885 let mut editor = make_editor();
4888 let tx = dummy_tx();
4889 editor.set_text(
4890 "aaaaaaaa
4891bb
4892cccccccc"
4893 .to_string(),
4894 );
4895 lay_out(&mut editor, 20, 10);
4896
4897 get_ta(&mut editor).jump_to(0, 7);
4898 arrow(&mut editor, &tx, KeyCode::Down);
4899 assert_eq!(
4900 get_ta(&mut editor).cursor(),
4901 (1, 2),
4902 "clamped to the short row"
4903 );
4904 arrow(&mut editor, &tx, KeyCode::Down);
4905 assert_eq!(
4906 get_ta(&mut editor).cursor(),
4907 (2, 7),
4908 "and back out to the cell the run still wants"
4909 );
4910 }
4911
4912 #[test]
4913 fn another_key_ends_the_run() {
4914 let mut editor = make_editor();
4915 let tx = dummy_tx();
4916 editor.set_text(
4917 "aaaaaaaa
4918bb
4919cccccccc"
4920 .to_string(),
4921 );
4922 lay_out(&mut editor, 20, 10);
4923
4924 get_ta(&mut editor).jump_to(0, 7);
4925 arrow(&mut editor, &tx, KeyCode::Down);
4926 arrow(&mut editor, &tx, KeyCode::Home);
4927 arrow(&mut editor, &tx, KeyCode::Down);
4928 assert_eq!(
4929 get_ta(&mut editor).cursor(),
4930 (2, 0),
4931 "Home set a new goal; the old one is gone"
4932 );
4933 }
4934
4935 #[test]
4936 fn shift_down_extends_by_a_drawn_line() {
4937 let mut editor = make_editor();
4938 let tx = dummy_tx();
4939 editor.set_text("aaaa bbbb cccc".to_string());
4940 lay_out(&mut editor, 6, 10);
4941
4942 get_ta(&mut editor).jump_to(0, 0);
4943 editor.handle_textarea_key(
4944 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
4945 &tx,
4946 );
4947 assert_eq!(
4948 get_ta(&mut editor).selection_range(),
4949 Some(((0, 0), (0, 5)))
4950 );
4951 }
4952
4953 fn make_vim_editor() -> TextEditorComponent {
4955 let mut settings = crate::settings::AppSettings::default();
4956 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
4957 TextEditorComponent::new(KeyBindings::empty(), &settings)
4958 }
4959
4960 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
4963 match &editor.backend {
4964 BackendState::Textarea(tb) => match &tb.input {
4965 backend::InputInterpreter::Vim(e) => e.mode().clone(),
4966 _ => panic!("expected Vim input interpreter"),
4967 },
4968 _ => panic!("expected Textarea backend"),
4969 }
4970 }
4971
4972 #[test]
4978 fn vim_visual_paste_url_wraps_whole_selected_word() {
4979 let mut editor = make_vim_editor();
4980 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4981 editor.set_text("hello world".to_string());
4982 editor.handle_input(
4985 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4986 &tx,
4987 );
4988 editor.handle_input(
4989 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4990 &tx,
4991 );
4992 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4993 editor.paste_text("https://example.com", &tx);
4994 assert_eq!(
4995 editor.get_text(),
4996 "[hello](https://example.com) world",
4997 "the whole selected word (including the char under the cursor) must be wrapped"
4998 );
4999 }
5000
5001 #[test]
5007 fn vim_visual_bold_wraps_whole_selected_word() {
5008 let mut editor = make_vim_editor();
5009 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5010 editor.set_text("hello world".to_string());
5011 editor.handle_input(
5012 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5013 &tx,
5014 );
5015 editor.handle_input(
5016 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5017 &tx,
5018 );
5019 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5020 editor.apply_text_action(TextAction::Bold);
5021 assert_eq!(
5022 editor.get_text(),
5023 "**hello** world",
5024 "the whole selected word (including the char under the cursor) must be wrapped"
5025 );
5026 }
5027
5028 #[test]
5036 fn paste_reports_its_outcome() {
5037 let mut editor = make_editor();
5038 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5039 editor.set_text("x".to_string());
5040 editor.paste_from_clipboard(&tx);
5041 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
5042 matches!(e, AppEvent::FlashMessage(m)
5043 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
5044 });
5045 assert!(reported, "a paste attempt must always report something");
5046 }
5047
5048 #[test]
5054 fn external_paste_drops_the_selection_and_leaves_visual() {
5055 let mut editor = make_vim_editor();
5056 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5057 editor.set_text("hello world".to_string());
5058 editor.handle_input(
5059 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5060 &tx,
5061 );
5062 editor.handle_input(
5063 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5064 &tx,
5065 );
5066 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5067
5068 editor.take_selection_for_external_paste();
5069
5070 assert_eq!(
5071 vim_mode(&editor),
5072 EditorMode::Normal,
5073 "the engine must not keep believing it is in Visual"
5074 );
5075 assert_eq!(
5076 get_ta(&mut editor).selection_range(),
5077 None,
5078 "the selection the incoming content replaces must be gone"
5079 );
5080 assert_eq!(
5081 editor.get_text(),
5082 " world",
5083 "the inclusive visual range is what gets replaced"
5084 );
5085 }
5086
5087 #[test]
5091 fn external_paste_over_visual_line_replaces_the_whole_rows() {
5092 let mut editor = make_vim_editor();
5093 let tx = dummy_tx();
5094 editor.set_text("a\nbb\ncc\nd".to_string());
5095 for c in ['j', 'l', 'V', 'j'] {
5096 editor.handle_input(
5097 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5098 &tx,
5099 );
5100 }
5101 editor.take_selection_for_external_paste();
5102 assert_eq!(editor.get_text(), "a\n\nd");
5103 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5104 }
5105
5106 #[test]
5108 fn bracketed_paste_over_visual_line_replaces_the_whole_rows() {
5109 let mut editor = make_vim_editor();
5110 let tx = dummy_tx();
5111 editor.set_text("a\nbb\ncc\nd".to_string());
5112 for c in ['j', 'l', 'V', 'j'] {
5113 editor.handle_input(
5114 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5115 &tx,
5116 );
5117 }
5118 editor.paste_text("X", &tx);
5119 assert_eq!(editor.get_text(), "a\nX\nd");
5120 }
5121
5122 #[test]
5125 fn surround_over_charwise_visual_includes_the_cursor_char() {
5126 let mut editor = make_vim_editor();
5127 let tx = dummy_tx();
5128 editor.set_text("hello world".to_string());
5129 for c in ['v', 'e', '*'] {
5130 editor.handle_input(
5131 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5132 &tx,
5133 );
5134 }
5135 assert_eq!(editor.get_text(), "*hello* world");
5136 }
5137
5138 #[test]
5141 fn surround_over_an_emoji_wraps_all_of_it() {
5142 let mut editor = make_vim_editor();
5143 let tx = dummy_tx();
5144 editor.set_text("a❤️b".to_string());
5145 for c in ['l', 'v', '*'] {
5146 editor.handle_input(
5147 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5148 &tx,
5149 );
5150 }
5151 assert_eq!(editor.get_text(), "a*❤️*b");
5152 }
5153
5154 #[test]
5156 fn visual_highlight_covers_the_whole_emoji() {
5157 let mut editor = make_vim_editor();
5158 let tx = dummy_tx();
5159 editor.set_text("a👍🏽b".to_string());
5160 for c in ['l', 'v'] {
5161 editor.handle_input(
5162 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5163 &tx,
5164 );
5165 }
5166 assert_eq!(editor.selection, Some(((0, 1), (0, 3))));
5167 }
5168
5169 #[test]
5173 fn bracketed_paste_over_visual_returns_to_normal() {
5174 let mut editor = make_vim_editor();
5175 let tx = dummy_tx();
5176 editor.set_text("keep\nabc".to_string());
5177 press(&mut editor, "jV");
5178 editor.paste_text("xyz", &tx);
5179 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5180 assert_eq!(editor.get_text(), "keep\nxyz");
5181 press(&mut editor, "0x");
5182 assert_eq!(editor.get_text(), "keep\nyz");
5183 }
5184
5185 #[test]
5188 fn two_emphases_over_a_visual_selection_nest_cleanly() {
5189 let mut editor = make_vim_editor();
5190 editor.set_text("hello world".to_string());
5191 press(&mut editor, "ve");
5192 editor.apply_text_action(TextAction::Bold);
5193 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5194 editor.apply_text_action(TextAction::Strikethrough);
5195 assert_eq!(editor.get_text(), "**~~hello~~** world");
5196 }
5197
5198 #[test]
5202 fn surround_chains_into_a_wikilink_from_visual() {
5203 let mut editor = make_vim_editor();
5204 editor.set_text("hello world".to_string());
5205 press(&mut editor, "ve[");
5206 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5207 press(&mut editor, "[");
5208 assert!(
5209 editor.get_text().starts_with("[[hello]]"),
5210 "{:?}",
5211 editor.get_text()
5212 );
5213 }
5214
5215 #[test]
5218 fn a_motion_after_a_surround_extends_the_visual_selection() {
5219 let mut editor = make_vim_editor();
5220 editor.set_text("hello world".to_string());
5221 press(&mut editor, "ve*");
5222 assert_eq!(editor.get_text(), "*hello* world");
5223 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5224 assert_eq!(editor.selection, Some(((0, 1), (0, 6))), "the inner text");
5225 }
5226
5227 #[test]
5230 fn a_surround_typed_in_insert_stays_in_insert() {
5231 let mut editor = make_vim_editor();
5232 editor.set_text("hello world".to_string());
5233 press(&mut editor, "i");
5234 {
5235 let ta = get_ta(&mut editor);
5236 ta.move_cursor(CursorMove::Jump(0, 0));
5237 ta.start_selection();
5238 ta.move_cursor(CursorMove::Jump(0, 5));
5239 }
5240 press(&mut editor, "(");
5241 assert_eq!(editor.get_text(), "(hello) world");
5242 assert_eq!(vim_mode(&editor), EditorMode::Insert);
5243 }
5244
5245 fn mouse(kind: ratatui::crossterm::event::MouseEventKind, column: u16) -> InputEvent {
5246 InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
5247 kind,
5248 column,
5249 row: 0,
5250 modifiers: KeyModifiers::NONE,
5251 })
5252 }
5253
5254 fn rendered_vim_editor(text: &str) -> TextEditorComponent {
5255 use ratatui::Terminal;
5256 use ratatui::backend::TestBackend;
5257 let mut editor = make_vim_editor();
5258 editor.set_text(text.to_string());
5259 let mut term = Terminal::new(TestBackend::new(30, 4)).unwrap();
5260 term.draw(|f| editor.render(f, Rect::new(0, 0, 30, 4), &Theme::default(), true))
5261 .unwrap();
5262 editor
5263 }
5264
5265 #[test]
5268 fn right_click_in_visual_copies_like_ctrl_c() {
5269 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5270 let tx = dummy_tx();
5271 for (text, keys) in [("hello world", "ve"), ("hello", "V"), ("\nx", "V")] {
5272 let mut editor = rendered_vim_editor(text);
5273 press(&mut editor, keys);
5274 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Right), 1), &tx);
5275 assert_eq!(
5276 vim_mode(&editor),
5277 EditorMode::Normal,
5278 "after `{keys}` on {text:?}"
5279 );
5280 assert_eq!(editor.selection, None);
5281 }
5282 }
5283
5284 #[test]
5287 fn a_drag_selects_exactly_what_was_dragged() {
5288 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5289 let tx = dummy_tx();
5290 for (from, to) in [(0, 5), (5, 0)] {
5291 let mut editor = rendered_vim_editor("hello world");
5292 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), from), &tx);
5293 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), to), &tx);
5294 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5295 assert_eq!(editor.selection, Some(((0, 0), (0, 5))), "drag {from}→{to}");
5296 press(&mut editor, "d");
5297 assert_eq!(editor.get_text(), " world", "drag {from}→{to}");
5298 }
5299 }
5300
5301 #[test]
5304 fn a_click_leaves_visual() {
5305 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5306 let tx = dummy_tx();
5307 for keys in ["vll", "V"] {
5308 let mut editor = rendered_vim_editor("hello world");
5309 press(&mut editor, keys);
5310 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 8), &tx);
5311 assert_eq!(vim_mode(&editor), EditorMode::Normal, "after `{keys}`");
5312 press(&mut editor, "x");
5313 assert_eq!(
5314 editor.get_text(),
5315 "hello wold",
5316 "after `{keys}`: a plain `x`"
5317 );
5318 }
5319 }
5320
5321 #[test]
5323 fn gv_after_a_click_reselects_what_the_click_ended() {
5324 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5325 let tx = dummy_tx();
5326 let mut editor = rendered_vim_editor("hello world");
5327 press(&mut editor, "vll");
5328 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 8), &tx);
5329 press(&mut editor, "gv");
5330 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5331 assert_eq!(editor.selection, Some(((0, 0), (0, 3))));
5332 }
5333
5334 #[test]
5337 fn scrolling_keeps_visual() {
5338 use ratatui::crossterm::event::MouseEventKind;
5339 let tx = dummy_tx();
5340 for keys in ["jV", "jv", "ve"] {
5341 let mut editor = rendered_vim_editor("a\n\nb");
5342 press(&mut editor, keys);
5343 let mode = vim_mode(&editor);
5344 editor.handle_input(&mouse(MouseEventKind::ScrollDown, 1), &tx);
5345 assert_eq!(vim_mode(&editor), mode, "after `{keys}`");
5346 }
5347 }
5348
5349 #[test]
5352 fn a_drag_without_its_own_press_selects_nothing() {
5353 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5354 let tx = dummy_tx();
5355 let mut editor = rendered_vim_editor("hello world");
5356 editor.open_note(note("alpha"), "hello world".to_string());
5357 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 2), &tx);
5358 editor.open_note(note("beta"), "other text".to_string());
5359 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 8), &tx);
5360 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5361 assert!(
5362 get_ta(&mut editor)
5363 .selection_range()
5364 .is_none_or(|(s, e)| s == e)
5365 );
5366
5367 let mut editor = rendered_vim_editor("hello world");
5368 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 2), &tx);
5369 editor.handle_input(&mouse(MouseEventKind::Up(MouseButton::Left), 2), &tx);
5370 get_ta(&mut editor).cancel_selection();
5371 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 8), &tx);
5372 assert_eq!(
5373 vim_mode(&editor),
5374 EditorMode::Normal,
5375 "a drag after the release"
5376 );
5377 }
5378
5379 #[test]
5381 fn a_one_character_drag_selects_it() {
5382 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5383 let tx = dummy_tx();
5384 let mut editor = rendered_vim_editor("hello");
5385 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 1), &tx);
5386 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 2), &tx);
5387 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5388 assert_eq!(editor.selection, Some(((0, 1), (0, 2))));
5389 press(&mut editor, "d");
5390 assert_eq!(editor.get_text(), "hllo");
5391 }
5392
5393 #[test]
5396 fn a_drag_that_turns_back_selects_from_its_origin() {
5397 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5398 let tx = dummy_tx();
5399 let mut editor = rendered_vim_editor("hello world");
5400 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 6), &tx);
5401 for col in [3, 1, 0, 9, 11] {
5402 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), col), &tx);
5403 }
5404 assert_eq!(editor.selection, Some(((0, 6), (0, 11))));
5405 press(&mut editor, "d");
5406 assert_eq!(editor.get_text(), "hello ");
5407 }
5408
5409 #[test]
5412 fn a_pair_char_over_an_empty_visual_row_does_nothing() {
5413 for keys in ["jV*", "jv*"] {
5414 let mut editor = make_vim_editor();
5415 editor.set_text("a\n\nb".to_string());
5416 press(&mut editor, keys);
5417 assert_eq!(editor.get_text(), "a\n\nb", "{keys}");
5418 assert_ne!(
5419 vim_mode(&editor),
5420 EditorMode::Normal,
5421 "{keys}: still selecting"
5422 );
5423 }
5424 }
5425
5426 #[test]
5427 fn emphasis_over_an_empty_visual_row_does_nothing() {
5428 let mut editor = make_vim_editor();
5429 editor.set_text("a\n\nb".to_string());
5430 press(&mut editor, "jv");
5431 editor.apply_text_action(TextAction::Bold);
5432 assert_eq!(editor.get_text(), "a\n\nb");
5433 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5434 }
5435
5436 #[test]
5440 fn a_wrap_ending_in_a_line_break_does_not_chain() {
5441 let mut editor = make_vim_editor();
5442 editor.set_text("a\n\nz".to_string());
5443 press(&mut editor, "vj*");
5444 assert_eq!(editor.get_text(), "*a\n*\nz");
5445 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5446 press(&mut editor, "*");
5447 assert_eq!(editor.get_text(), "*a\n*\nz");
5448 }
5449
5450 #[test]
5452 fn emphasis_from_visual_line_keeps_the_row_text_selected() {
5453 let mut editor = make_vim_editor();
5454 editor.set_text("hello\nx".to_string());
5455 press(&mut editor, "V");
5456 editor.apply_text_action(TextAction::Bold);
5457 assert_eq!(editor.get_text(), "**hello**\nx");
5458 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5459 assert_eq!(editor.selection, Some(((0, 2), (0, 7))));
5460 }
5461
5462 #[test]
5464 fn surround_over_visual_line_wraps_the_whole_row() {
5465 let mut editor = make_vim_editor();
5466 let tx = dummy_tx();
5467 editor.set_text("a\nbold\nc".to_string());
5468 for c in ['j', 'l', 'V', '*'] {
5469 editor.handle_input(
5470 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5471 &tx,
5472 );
5473 }
5474 assert_eq!(editor.get_text(), "a\n*bold*\nc");
5475 }
5476
5477 #[test]
5480 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
5481 let mut editor = make_vim_editor();
5482 editor.set_text("hello world".to_string());
5483 editor.take_selection_for_external_paste();
5484 assert_eq!(editor.get_text(), "hello world");
5485 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5486 }
5487
5488 #[test]
5495 fn vim_visual_copy_leaves_visual_and_nothing_to_grow() {
5496 let mut editor = make_vim_editor();
5497 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5498 editor.set_text("hello world".to_string());
5499 press(&mut editor, "ve");
5500 assert_eq!(
5501 editor.backend.selection_as_shown(),
5502 Some(((0, 0), (0, 5))),
5503 "the copy reads the inclusive range including the cursor char"
5504 );
5505 editor.copy_selection_to_clipboard(&tx);
5506 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5507 assert_eq!(get_ta(&mut editor).selection_range(), None);
5508 editor.copy_selection_to_clipboard(&tx);
5509 assert_eq!(get_ta(&mut editor).selection_range(), None);
5510 assert_eq!(
5511 get_ta(&mut editor).cursor(),
5512 (0, 0),
5513 "at the copied range's start"
5514 );
5515 }
5516
5517 #[test]
5525 fn vim_visual_line_selection_stays_full_width_after_cursor_moves_back() {
5526 let mut editor = make_vim_editor();
5527 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5528 editor.set_text("hello world".to_string());
5529 editor.handle_input(
5530 &InputEvent::Key(key(KeyCode::Char('V'), KeyModifiers::NONE)),
5531 &tx,
5532 );
5533 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5534 assert_eq!(
5535 editor.selection,
5536 Some(((0, 0), (0, usize::MAX))),
5537 "V must highlight the full line right away"
5538 );
5539 for _ in 0..5 {
5540 editor.handle_input(
5541 &InputEvent::Key(key(KeyCode::Left, KeyModifiers::NONE)),
5542 &tx,
5543 );
5544 }
5545 assert_eq!(
5546 editor.selection,
5547 Some(((0, 0), (0, usize::MAX))),
5548 "moving the cursor back must not shrink the linewise highlight"
5549 );
5550 }
5551
5552 #[test]
5559 fn vim_visual_outdent_with_nothing_to_remove_clears_the_highlight() {
5560 let mut editor = make_vim_editor();
5561 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5562 editor.set_text("alpha\nbeta".to_string());
5563 for c in ['V', 'j'] {
5564 editor.handle_input(
5565 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5566 &tx,
5567 );
5568 }
5569 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5570 assert!(
5571 editor.selection.is_some(),
5572 "V over two rows must paint a highlight"
5573 );
5574
5575 editor.handle_input(
5576 &InputEvent::Key(key(KeyCode::Char('<'), KeyModifiers::NONE)),
5577 &tx,
5578 );
5579 assert_eq!(
5580 editor.get_text(),
5581 "alpha\nbeta",
5582 "there is no indentation to remove"
5583 );
5584 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5585 assert_eq!(
5586 editor.selection, None,
5587 "leaving Visual mode must clear the highlight even when the outdent changed nothing"
5588 );
5589 }
5590
5591 #[test]
5601 fn vim_sync_collapsed_sel_stays_normal() {
5602 let mut editor = make_vim_editor();
5603 editor.set_text("hello world".to_string());
5604
5605 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5607
5608 editor.backend.sync_mouse_selection(false);
5611 assert_eq!(
5612 vim_mode(&editor),
5613 EditorMode::Normal,
5614 "collapsed (bare click) selection must not enter Visual mode"
5615 );
5616 }
5617
5618 #[test]
5620 fn vim_sync_real_sel_enters_visual() {
5621 let mut editor = make_vim_editor();
5622 editor.set_text("hello world".to_string());
5623
5624 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5626
5627 editor.backend.sync_mouse_selection(true);
5629 assert_eq!(
5630 vim_mode(&editor),
5631 EditorMode::Visual,
5632 "real drag selection must enter Visual mode"
5633 );
5634 }
5635
5636 #[test]
5640 fn vim_find_bar_captures_typing_not_cursor() {
5641 let mut editor = make_vim_editor();
5642 editor.set_text("hello world\nsecond line".to_string());
5643 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5644
5645 editor.open_or_advance_search();
5647 assert!(editor.search.is_some(), "find bar must be open");
5648
5649 editor.handle_input(
5651 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5652 &tx,
5653 );
5654 editor.handle_input(
5655 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5656 &tx,
5657 );
5658
5659 let q = editor
5663 .search
5664 .as_ref()
5665 .map(|s| s.input.value().to_string())
5666 .unwrap_or_default();
5667 assert_eq!(q, "lo", "find query must capture typed characters");
5668
5669 assert_eq!(
5672 editor.get_text(),
5673 "hello world\nsecond line",
5674 "buffer must not be modified while find bar is open"
5675 );
5676
5677 assert_eq!(
5683 editor.cursor_pos().1,
5684 3,
5685 "cursor must jump to the search match (col 3), not to a vim motion position"
5686 );
5687 }
5688
5689 #[test]
5694 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
5695 let mut editor = make_vim_editor();
5696 editor.set_text("lo xx lo yy lo".to_string());
5698 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5699
5700 editor.open_or_advance_search();
5702 assert!(editor.search.is_some(), "find bar must open");
5703
5704 editor.handle_input(
5706 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5707 &tx,
5708 );
5709 editor.handle_input(
5710 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5711 &tx,
5712 );
5713
5714 editor.handle_input(
5718 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
5719 &tx,
5720 );
5721 assert!(
5722 editor.search.is_some(),
5723 "find bar stays open on Enter — it steps, it does not confirm"
5724 );
5725 let (_, c1) = editor.cursor_pos();
5726 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
5727
5728 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
5732 assert!(editor.search.is_none(), "Esc must close the find bar");
5733
5734 editor.handle_input(
5736 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
5737 &tx,
5738 );
5739 let (_, c2) = editor.cursor_pos();
5740 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
5741
5742 assert_eq!(editor.get_text(), "lo xx lo yy lo");
5744 }
5745}